@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,107 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ sweep_post_link_clicks.py — behavioral bot-flagger for short-link click logs.
4
+
5
+ Runs in addition to the per-hit UA regex in @m13v/seo-components. The UA
6
+ regex catches obvious crawlers; this sweep catches everything that looks
7
+ human in isolation but stops looking human when you correlate hits across
8
+ ip_hash + code + post + time.
9
+
10
+ All five rules + the R2 per-post excess loop + the counter rebuild now run
11
+ server-side in POST /api/v1/post-links/clicks-sweep (HTTP-only, 2026-06-01;
12
+ no DATABASE_URL on the operator box). This script is a thin trigger that
13
+ POSTs the flags and prints the returned before/flips/after/counter numbers.
14
+
15
+ Rules (all idempotent — re-running won't double-flag):
16
+
17
+ Tier 1 (zero false positives):
18
+ R1 same ip_hash + same code + >=3 hits in a 240s sliding window
19
+ R2 clicks on a post exceed views * platform_ctr_ceiling
20
+ R3 same ip_hash hits >=5 different codes within the window
21
+
22
+ Tier 2 (very low false positives, applied after Tier 1):
23
+ R4 no referrer + browser-looking UA + ip_hash co-occurs with bot rows
24
+ R5 same ip_hash hits >=4 different codes within any 60-second window
25
+
26
+ Each flipped row records the rule in `bot_reason` so we can audit and roll
27
+ back per-rule if a false positive shows up. After flipping, the counter
28
+ post_links.clicks is rebuilt from the per-hit log so the dashboard matches.
29
+
30
+ Usage:
31
+ scripts/sweep_post_link_clicks.py [--dry-run] [--lookback-hours N]
32
+ [--rules R1,R2,R3,R4,R5]
33
+ [--cron] [--rebuild-counter]
34
+
35
+ --lookback-hours N only consider clicks newer than N hours (default 720
36
+ on first/manual run, 6 in --cron mode)
37
+ --cron quick-sweep mode: 6h lookback, decrement-only counter
38
+ --rebuild-counter full SUM(NOT is_bot) rebuild of post_links.clicks
39
+
40
+ Idempotent: only flips rows where is_bot=false today; never un-flips.
41
+ """
42
+
43
+ from __future__ import annotations
44
+
45
+ import argparse
46
+ import os
47
+ import sys
48
+
49
+ REPO_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
50
+ sys.path.insert(0, os.path.join(REPO_DIR, "scripts"))
51
+
52
+ from http_api import api_post, load_env # noqa: E402
53
+
54
+
55
+ def main():
56
+ ap = argparse.ArgumentParser()
57
+ ap.add_argument("--dry-run", action="store_true")
58
+ ap.add_argument("--lookback-hours", type=int, default=None)
59
+ ap.add_argument("--cron", action="store_true",
60
+ help="quick-sweep mode: 6h lookback, decrement-only counter update")
61
+ ap.add_argument("--rebuild-counter", action="store_true",
62
+ help="full counter rebuild from is_bot=false rows (safe, idempotent)")
63
+ ap.add_argument("--rules", default="R1,R2,R3,R4,R5",
64
+ help="comma-separated rule list, default all five")
65
+ args = ap.parse_args()
66
+
67
+ load_env()
68
+
69
+ rules = [r.strip().upper() for r in args.rules.split(",") if r.strip()]
70
+ body = {
71
+ "dry_run": args.dry_run,
72
+ "cron": args.cron,
73
+ "rebuild_counter": args.rebuild_counter,
74
+ "rules": rules,
75
+ }
76
+ if args.lookback_hours is not None:
77
+ body["lookback_hours"] = int(args.lookback_hours)
78
+
79
+ resp = api_post("/api/v1/post-links/clicks-sweep", body)
80
+ data = resp.get("data") or {}
81
+
82
+ window = data.get("window_hours")
83
+ before = data.get("before") or {}
84
+ after = data.get("after") or {}
85
+ flips = data.get("flips") or {}
86
+ counter = data.get("counter") or {}
87
+
88
+ print(f"[before] window={window}h humans={before.get('humans')} "
89
+ f"bots={before.get('bots')} total={before.get('total')}", flush=True)
90
+ print("[flips]", " ".join(f"{k}={flips[k]}" for k in sorted(flips)), flush=True)
91
+ print(f"[after] window={window}h humans={after.get('humans')} "
92
+ f"bots={after.get('bots')} total={after.get('total')}", flush=True)
93
+
94
+ mode = counter.get("mode")
95
+ if mode == "dry-run":
96
+ print(f"[counter] dry-run: would change SUM by ~{counter.get('would_change_sum')}; "
97
+ f"humans-total now {counter.get('humans_total')}", flush=True)
98
+ elif mode == "cron":
99
+ print(f"[counter] cron-mode: rebuilt counters for codes touching "
100
+ f"{counter.get('flagged_rows_touched')} flagged rows", flush=True)
101
+ elif mode == "full-rebuild":
102
+ print(f"[counter] full rebuild done; SUM(post_links.clicks) now = "
103
+ f"{counter.get('sum_after')}", flush=True)
104
+
105
+
106
+ if __name__ == "__main__":
107
+ main()
@@ -0,0 +1,147 @@
1
+ #!/usr/bin/env python3
2
+ """Mirror posted Instagram rows from media_posts -> posts.
3
+
4
+ media_posts holds the IG-only fields (video_path, post_type, target_account,
5
+ overlays, source_clips, composition_id). posts holds the platform-agnostic
6
+ fields the dashboard reads (platform, our_url, our_content, our_account,
7
+ posted_at, upvotes, comments_count, views, engagement_updated_at).
8
+
9
+ This script copies the dashboard-essential fields across so the existing
10
+ dashboard surfaces (Trends, Top, Activity, Stats by Engagement Style, Cohort)
11
+ treat Instagram identically to Reddit/Twitter/LinkedIn.
12
+
13
+ Idempotent: skips rows already mirrored (matched on platform='instagram' AND
14
+ our_url=<IG permalink>). Safe to rerun. Called at end of run-instagram-daily.sh
15
+ so new posts mirror immediately, and once on-demand for backfill.
16
+
17
+ Usage:
18
+ python3 scripts/sync_ig_to_posts.py [--quiet] [--limit N]
19
+ """
20
+
21
+ import argparse
22
+ import json
23
+ import os
24
+ import sys
25
+
26
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
27
+ from http_api import api_get, api_post
28
+
29
+
30
+ def log(msg, quiet=False):
31
+ if not quiet:
32
+ print(msg)
33
+
34
+
35
+ def _load_canonical_style_names(quiet=False):
36
+ """Return the set of allowlisted engagement_style names.
37
+
38
+ Union of the hardcoded STYLES dict + the registry (seed + model_invented +
39
+ human_derived rows). Used to gate what we write to posts.engagement_style:
40
+ Claude sometimes stamps caption-style metadata with non-canonical labels
41
+ (e.g. 'studyly-rescue-arc') that never went through validate_or_register
42
+ on the IG render path. We refuse to mirror those into posts so they don't
43
+ pollute the dashboard's engagement-style A/B picker.
44
+ """
45
+ names = set()
46
+ try:
47
+ from engagement_styles import get_all_styles
48
+ names.update((get_all_styles() or {}).keys())
49
+ except Exception as e:
50
+ log(f"[sync] WARNING — could not load canonical styles: {e!r}", quiet)
51
+ return names
52
+
53
+
54
+ def main():
55
+ parser = argparse.ArgumentParser()
56
+ parser.add_argument("--quiet", action="store_true")
57
+ parser.add_argument("--limit", type=int, default=None,
58
+ help="Cap rows processed (for testing).")
59
+ args = parser.parse_args()
60
+
61
+ # 2026-05-25: gate posts.engagement_style writes against the canonical
62
+ # registry. IG renders pre-pick a style (run-instagram-render.sh) and ask
63
+ # Claude to stamp metadata.engagement_style=<picked>, but Claude has been
64
+ # observed writing caption_style/description_style with off-list labels
65
+ # (e.g. 'studyly-rescue-arc') instead. Mirroring those into posts.* lets
66
+ # them pollute the engagement_style A/B picker. We mirror NULL for any
67
+ # value not in the canonical set; the orphan label remains in
68
+ # media_posts.metadata for forensics.
69
+ canonical_styles = _load_canonical_style_names(args.quiet)
70
+
71
+ query = {}
72
+ if args.limit:
73
+ query["limit"] = int(args.limit)
74
+ resp = api_get("/api/v1/media-posts/posted-instagram", query=query or None)
75
+ rows = (resp.get("data") or {}).get("rows") or []
76
+ log(f"[sync] media_posts: {len(rows)} posted IG rows", args.quiet)
77
+
78
+ inserted = 0
79
+ skipped = 0
80
+ for r in rows:
81
+ posted_urls = r["posted_urls"]
82
+ if isinstance(posted_urls, str):
83
+ posted_urls = json.loads(posted_urls)
84
+ ig_url = (posted_urls or {}).get("instagram")
85
+ if not ig_url:
86
+ continue
87
+
88
+ # thread_url is NOT NULL; for original posts we self-reference
89
+ # (established pattern, 2,124 rows across other platforms).
90
+ metadata = r["metadata"]
91
+ if isinstance(metadata, str):
92
+ metadata = json.loads(metadata)
93
+ elif metadata is None:
94
+ metadata = {}
95
+ engagement_style = metadata.get("engagement_style") or metadata.get("caption_style")
96
+ if engagement_style and canonical_styles and engagement_style not in canonical_styles:
97
+ log(f"[sync] WARNING: dropping non-canonical engagement_style "
98
+ f"{engagement_style!r} for post-{r['post_number']} "
99
+ f"({r['target_account']}); mirroring NULL", args.quiet)
100
+ engagement_style = None
101
+
102
+ # The mirror endpoint is idempotent on (platform='instagram',
103
+ # our_url=ig_url): inserted=false means the row was already mirrored.
104
+ result = api_post(
105
+ "/api/v1/posts/mirror-instagram",
106
+ {
107
+ "ig_url": ig_url,
108
+ "caption_text": r["caption_text"] or "",
109
+ "target_account": r["target_account"] or "matt_diak",
110
+ "posted_at": r["posted_at"],
111
+ "project_name": r["project_name"],
112
+ "engagement_style": engagement_style,
113
+ },
114
+ )
115
+ rdata = result.get("data") or {}
116
+ posts_id = rdata.get("id")
117
+ if not rdata.get("inserted"):
118
+ skipped += 1
119
+ continue
120
+ inserted += 1
121
+ log(f"[sync] inserted post-{r['post_number']} ({r['target_account']}) -> {ig_url}", args.quiet)
122
+
123
+ # Attribute the freshly-mirrored posts row to any campaign that fired
124
+ # at post time. post_to_ig.py records metadata.applied_campaign_ids on
125
+ # the media_posts row (AI-disclosure labeling experiment etc.); forward
126
+ # each to /api/v1/campaigns/bump, which is idempotent (sets
127
+ # posts.campaign_id and advances the counter only on first attribution).
128
+ # Best-effort: a bump failure must not abort the sync.
129
+ applied = metadata.get("applied_campaign_ids") or []
130
+ if applied and posts_id:
131
+ for cid in applied:
132
+ try:
133
+ api_post(
134
+ "/api/v1/campaigns/bump",
135
+ {"table": "posts", "id": int(posts_id), "campaign_id": int(cid)},
136
+ )
137
+ log(f"[sync] campaign {cid} -> posts.id={posts_id}", args.quiet)
138
+ except SystemExit as e:
139
+ log(f"[sync] WARNING campaign {cid} bump failed: {e}", args.quiet)
140
+ except Exception as e:
141
+ log(f"[sync] WARNING campaign {cid} bump failed: {e}", args.quiet)
142
+
143
+ log(f"[sync] done: inserted={inserted} skipped_existing={skipped} total_scanned={len(rows)}", args.quiet)
144
+
145
+
146
+ if __name__ == "__main__":
147
+ main()
@@ -0,0 +1,189 @@
1
+ #!/usr/bin/env python3
2
+ """Regression test for the browser session-lock fix (2026-06-16).
3
+
4
+ Covers BOTH twitter_browser.py and linkedin_browser.py (same fix, ported). With
5
+ NO real browser, it exercises the three session-lock defects:
6
+ (a) dead python:PID holders must be reclaimed immediately (not after 300s)
7
+ (b) [shell-side, verified separately] no `rm -f` of the lockfile in pipelines
8
+ (c) lock acquisition must be atomic (two acquirers cannot both win)
9
+
10
+ Run:
11
+ /opt/homebrew/bin/python3 scripts/test_browser_lock.py
12
+ Exit 0 = all pass; non-zero with FAIL lines otherwise.
13
+
14
+ Canonical "did the fix survive / still work?" check. See
15
+ docs/twitter_browser_lock.md for the full verification playbook.
16
+ """
17
+ import io
18
+ import json
19
+ import os
20
+ import subprocess
21
+ import sys
22
+ import tempfile
23
+ import time
24
+ from contextlib import redirect_stderr, redirect_stdout
25
+
26
+ HERE = os.path.dirname(os.path.abspath(__file__))
27
+ for _cand in (HERE, os.path.join(HERE, "..")):
28
+ if os.path.exists(os.path.join(_cand, "twitter_browser.py")):
29
+ sys.path.insert(0, _cand)
30
+ break
31
+ import twitter_browser # noqa: E402
32
+ import linkedin_browser # noqa: E402
33
+
34
+ FAILS = []
35
+
36
+
37
+ def check(name, cond, detail=""):
38
+ print(f"{'PASS' if cond else 'FAIL'} {name}" + (f" -- {detail}" if detail else ""))
39
+ if not cond:
40
+ FAILS.append(name)
41
+
42
+
43
+ def write_lock(mod, holder, ts):
44
+ with open(mod.LOCK_FILE, "w") as f:
45
+ json.dump({"session_id": holder, "timestamp": ts}, f)
46
+
47
+
48
+ def read_holder(mod):
49
+ with open(mod.LOCK_FILE) as f:
50
+ return json.load(f)["session_id"]
51
+
52
+
53
+ def reset(mod):
54
+ mod._LOCK_SESSION_ID = f"python:{os.getpid()}"
55
+ mod._LOCK_INHERITED = False
56
+ try:
57
+ os.remove(mod.LOCK_FILE)
58
+ except OSError:
59
+ pass
60
+
61
+
62
+ def dead_pid():
63
+ p = subprocess.Popen(["true"])
64
+ p.wait()
65
+ try:
66
+ os.kill(p.pid, 0)
67
+ return None
68
+ except ProcessLookupError:
69
+ return p.pid
70
+
71
+
72
+ def run_suite(mod, giveup_substrings):
73
+ P = mod.__name__ # prefix for check names
74
+ tmpdir = tempfile.mkdtemp(prefix=f"brlock-{P}-")
75
+ mod.LOCK_FILE = os.path.join(tmpdir, "lock.json")
76
+ mod.LOCK_WAIT_MAX = 2
77
+ mod.LOCK_POLL_INTERVAL = 0.2
78
+ print(f"\n# {P}: LOCK_FILE={mod.LOCK_FILE} LOCK_WAIT_MAX={mod.LOCK_WAIT_MAX}")
79
+
80
+ check(f"{P}.fix_present: _is_python_holder_alive", hasattr(mod, "_is_python_holder_alive"))
81
+ check(f"{P}.fix_present: _try_take_lock (atomic)", hasattr(mod, "_try_take_lock"))
82
+
83
+ # (c) atomic take
84
+ reset(mod)
85
+ first = mod._try_take_lock()
86
+ second = mod._try_take_lock()
87
+ check(f"{P}.c.atomic_take: first wins, second loses", first is True and second is False,
88
+ f"first={first} second={second}")
89
+ check(f"{P}.c.atomic_take: file holds our id", read_holder(mod) == mod._LOCK_SESSION_ID)
90
+
91
+ # (a) dead python:PID holder reclaimed immediately
92
+ reset(mod)
93
+ dp = dead_pid()
94
+ if dp is None:
95
+ check(f"{P}.a.dead_python_reclaim", False, "could not obtain a dead pid")
96
+ else:
97
+ write_lock(mod, f"python:{dp}", int(time.time())) # RECENT ts
98
+ err = io.StringIO()
99
+ t0 = time.time()
100
+ with redirect_stderr(err):
101
+ mod._acquire_browser_lock()
102
+ elapsed = time.time() - t0
103
+ check(f"{P}.a.dead_python_reclaim: fast (<1s, not LOCK_WAIT_MAX)", elapsed < 1.0,
104
+ f"elapsed={elapsed:.2f}s")
105
+ check(f"{P}.a.dead_python_reclaim: lock now ours", read_holder(mod) == mod._LOCK_SESSION_ID)
106
+ check(f"{P}.a.dead_python_reclaim: marker reason=dead_python",
107
+ "reclaimed" in err.getvalue() and "reason=dead_python" in err.getvalue(),
108
+ err.getvalue().strip())
109
+
110
+ # LIVE python peer -> wait then give up
111
+ reset(mod)
112
+ peer = subprocess.Popen(["sleep", "30"])
113
+ try:
114
+ write_lock(mod, f"python:{peer.pid}", int(time.time()))
115
+ out, err = io.StringIO(), io.StringIO()
116
+ t0 = time.time()
117
+ code = None
118
+ try:
119
+ with redirect_stdout(out), redirect_stderr(err):
120
+ mod._acquire_browser_lock()
121
+ except SystemExit as e:
122
+ code = e.code
123
+ elapsed = time.time() - t0
124
+ check(f"{P}.live_peer.giveup: exits 1", code == 1, f"code={code}")
125
+ check(f"{P}.live_peer.giveup: waited ~LOCK_WAIT_MAX", elapsed >= mod.LOCK_WAIT_MAX * 0.8,
126
+ f"elapsed={elapsed:.2f}s")
127
+ payload = out.getvalue()
128
+ for sub in giveup_substrings:
129
+ check(f"{P}.live_peer.giveup: payload has '{sub}'", sub in payload, payload.strip())
130
+ finally:
131
+ peer.terminate()
132
+ peer.wait()
133
+
134
+ # re-entrant -> take fast, refresh timestamp
135
+ reset(mod)
136
+ old_ts = int(time.time()) - 120
137
+ write_lock(mod, mod._LOCK_SESSION_ID, old_ts)
138
+ t0 = time.time()
139
+ mod._acquire_browser_lock()
140
+ check(f"{P}.reentrant: fast", time.time() - t0 < 1.0)
141
+ with open(mod.LOCK_FILE) as f:
142
+ new_ts = json.load(f)["timestamp"]
143
+ check(f"{P}.reentrant: timestamp refreshed", new_ts > old_ts, f"old={old_ts} new={new_ts}")
144
+
145
+ # dead UUID holder -> reclaim
146
+ reset(mod)
147
+ write_lock(mod, "deadbeef-0000-0000-0000-000000000000", int(time.time()))
148
+ err = io.StringIO()
149
+ with redirect_stderr(err):
150
+ mod._acquire_browser_lock()
151
+ check(f"{P}.dead_uuid_reclaim: lock now ours", read_holder(mod) == mod._LOCK_SESSION_ID)
152
+ check(f"{P}.dead_uuid_reclaim: marker reason=dead_uuid", "reason=dead_uuid" in err.getvalue(),
153
+ err.getvalue().strip())
154
+
155
+ # expired holder -> reclaim
156
+ reset(mod)
157
+ write_lock(mod, "weird:holder:form", int(time.time()) - (mod.LOCK_EXPIRY + 50))
158
+ err = io.StringIO()
159
+ with redirect_stderr(err):
160
+ mod._acquire_browser_lock()
161
+ check(f"{P}.expired_reclaim: lock now ours", read_holder(mod) == mod._LOCK_SESSION_ID)
162
+ check(f"{P}.expired_reclaim: marker reason=expired", "reason=expired" in err.getvalue(),
163
+ err.getvalue().strip())
164
+
165
+ # cold start -> fast + silent
166
+ reset(mod)
167
+ err = io.StringIO()
168
+ t0 = time.time()
169
+ with redirect_stderr(err):
170
+ mod._acquire_browser_lock()
171
+ check(f"{P}.cold_start: fast + silent", (time.time() - t0) < 1.0 and "reclaim" not in err.getvalue())
172
+
173
+ try:
174
+ os.remove(mod.LOCK_FILE)
175
+ except OSError:
176
+ pass
177
+ os.rmdir(tmpdir)
178
+
179
+
180
+ # twitter giveup: {"success": false, "error": "...locked by session ... peer alive..."}
181
+ run_suite(twitter_browser, ["locked by session", "peer alive"])
182
+ # linkedin giveup: {"ok": false, "error": "profile_locked", "detail": "...peer_alive=1"}
183
+ run_suite(linkedin_browser, ["profile_locked", "peer_alive"])
184
+
185
+ print()
186
+ if FAILS:
187
+ print(f"RESULT: {len(FAILS)} FAILED -> {FAILS}")
188
+ sys.exit(1)
189
+ print("RESULT: ALL PASS")
@@ -0,0 +1,52 @@
1
+ #!/usr/bin/env bash
2
+ # End-to-end smoke test for the installation auth lane.
3
+ #
4
+ # Run AFTER:
5
+ # 1. ~/social-autoposter-website/scripts/migrate-installations.sql applied
6
+ # against $DATABASE_URL.
7
+ # 2. Latest social-autoposter-website deployed to Vercel.
8
+ #
9
+ # Hits:
10
+ # - GET heartbeat with no header (expect 400)
11
+ # - POST heartbeat with header (expect 200 + installation row)
12
+ # - GET heartbeat with header (expect 200 + same installation row)
13
+ # - GET /api/v1/replies?limit=1 (expect 200 with install header, no bearer)
14
+ #
15
+ # No data is inserted to `replies` here; this only exercises the auth path.
16
+
17
+ set -euo pipefail
18
+
19
+ BASE_URL="${BASE_URL:-https://s4l.ai}"
20
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
21
+
22
+ PYTHON_BIN="${PYTHON_BIN:-python3}"
23
+ HDR=$("$PYTHON_BIN" "$SCRIPT_DIR/identity.py" header)
24
+ echo "install_id: $("$PYTHON_BIN" -c "import base64,json,sys; d=json.loads(base64.b64decode(sys.argv[1])); print(d['install_id'])" "$HDR")"
25
+ echo "base_url: $BASE_URL"
26
+ echo
27
+
28
+ step() { echo; echo "=== $1 ==="; }
29
+
30
+ step "1) GET /api/v1/installations/heartbeat (no header) -> expect 400"
31
+ curl -sS -w "\nHTTP %{http_code}\n" "$BASE_URL/api/v1/installations/heartbeat" | tail -20
32
+
33
+ step "2) POST /api/v1/installations/heartbeat (with header) -> expect 200"
34
+ curl -sS -w "\nHTTP %{http_code}\n" \
35
+ -X POST \
36
+ -H "X-Installation: $HDR" \
37
+ -H "content-type: application/json" \
38
+ -d '{}' \
39
+ "$BASE_URL/api/v1/installations/heartbeat" | tail -40
40
+
41
+ step "3) GET /api/v1/installations/heartbeat (with header) -> expect 200"
42
+ curl -sS -w "\nHTTP %{http_code}\n" \
43
+ -H "X-Installation: $HDR" \
44
+ "$BASE_URL/api/v1/installations/heartbeat" | tail -40
45
+
46
+ step "4) GET /api/v1/replies?limit=1 (with install header, no bearer) -> expect 200"
47
+ curl -sS -w "\nHTTP %{http_code}\n" \
48
+ -H "X-Installation: $HDR" \
49
+ "$BASE_URL/api/v1/replies?limit=1" | tail -10
50
+
51
+ echo
52
+ echo "Done. If all 4 returned the expected codes, the install lane is wired."
@@ -0,0 +1,142 @@
1
+ """Headless logic test for per-card serialized posting in the menu bar.
2
+
3
+ Stubs the heavy deps (rumps / sentry_init / s4l_state) so s4l_menubar imports
4
+ without AppKit, then drives the REAL _on_card_decision + _post_worker_loop on an
5
+ instance built via object.__new__ (bypassing rumps.App.__init__). Verifies:
6
+ 1. posts run strictly one-at-a-time (no overlap on the shared browser)
7
+ 2. order is preserved (FIFO)
8
+ 3. plain approvals -> post=[n]; edited approvals -> edits=[{n,text}]
9
+ 4. rejected cards never post
10
+ 5. _posts_outstanding / _review_active settle to idle once drained
11
+ 6. activity progress reflects the approved burst total, not each 1-item call
12
+ """
13
+ import os
14
+ import queue
15
+ import sys
16
+ import threading
17
+ import time
18
+ import types
19
+
20
+ HERE = os.path.join(os.path.dirname(__file__), "..", "mcp", "menubar")
21
+ sys.path.insert(0, os.path.abspath(HERE))
22
+
23
+ # --- stub heavy deps so the import is headless --------------------------------
24
+ rumps = types.ModuleType("rumps")
25
+ class _App:
26
+ def __init__(self, *a, **k):
27
+ pass
28
+ rumps.App = _App
29
+ rumps.Timer = lambda *a, **k: types.SimpleNamespace(start=lambda: None, stop=lambda: None)
30
+ rumps.MenuItem = lambda *a, **k: object()
31
+ rumps.separator = object()
32
+ rumps.notification = lambda *a, **k: None
33
+ sys.modules["rumps"] = rumps
34
+
35
+ sentry_init = types.ModuleType("sentry_init")
36
+ sentry_init.init_sentry = lambda *a, **k: None
37
+ sentry_init.capture = lambda *a, **k: None
38
+ sys.modules["sentry_init"] = sentry_init
39
+
40
+ # Track concurrency + record every post_drafts call.
41
+ overlap_detected = []
42
+ inflight = {"n": 0}
43
+ inflight_lock = threading.Lock()
44
+ calls = []
45
+ activity_events = []
46
+
47
+ def fake_post_drafts(batch_id, post=None, edits=None, timeout=900, activity_label=None):
48
+ with inflight_lock:
49
+ inflight["n"] += 1
50
+ if inflight["n"] > 1:
51
+ overlap_detected.append(True)
52
+ calls.append(
53
+ {
54
+ "batch": batch_id,
55
+ "post": post or [],
56
+ "edits": edits or [],
57
+ "activity_label": activity_label,
58
+ }
59
+ )
60
+ time.sleep(0.15) # simulate a slow post so overlaps would be caught
61
+ with inflight_lock:
62
+ inflight["n"] -= 1
63
+ # mimic the real shape: posted count
64
+ n_posted = len(post or []) + len(edits or [])
65
+ return {"posted": n_posted}
66
+
67
+ st = types.ModuleType("s4l_state")
68
+ st.post_drafts = fake_post_drafts
69
+ st.write_activity = lambda state, label: activity_events.append((state, label))
70
+ st.accessibility_trusted = lambda: True
71
+ st.clear_review_request = lambda: None
72
+ sys.modules["s4l_state"] = st
73
+
74
+ import s4l_menubar # noqa: E402
75
+
76
+ # --- build an instance without running rumps.App.__init__ ---------------------
77
+ app = object.__new__(s4l_menubar.S4LMenuBar)
78
+ app._post_q = queue.Queue()
79
+ app._post_worker = None
80
+ app._review_lock = threading.Lock()
81
+ app._panel_open = True
82
+ app._posts_outstanding = 0
83
+ app._posting_batch_total = 0
84
+ app._posting_batch_done = 0
85
+ app._review_active = False
86
+ app._notify = lambda title, msg: None # silence Notification Center
87
+
88
+ BATCH = "review-queue"
89
+
90
+ # Approve a quick burst (as if the user clicked Approve on several cards fast),
91
+ # one edited, plus a rejected card that must NOT post.
92
+ decisions = [
93
+ {"n": 1, "approved": True, "text": "reply one", "edited": False},
94
+ {"n": 2, "approved": True, "text": "edited two", "edited": True},
95
+ {"n": 3, "approved": False, "text": "skip", "edited": False},
96
+ {"n": 4, "approved": True, "text": "reply four", "edited": False},
97
+ ]
98
+ for d in decisions:
99
+ app._on_card_decision(BATCH, d)
100
+ time.sleep(0.02) # tight succession -> overlap would happen if not serialized
101
+
102
+ # Panel closes while posts may still be draining.
103
+ app._on_review_closed(BATCH, decisions)
104
+
105
+ # Wait for the queue to drain.
106
+ deadline = time.time() + 10
107
+ while time.time() < deadline:
108
+ with app._review_lock:
109
+ if app._posts_outstanding == 0 and app._post_q.empty():
110
+ break
111
+ time.sleep(0.05)
112
+
113
+ # --- assertions ---------------------------------------------------------------
114
+ fail = []
115
+ if overlap_detected:
116
+ fail.append(f"posts overlapped ({len(overlap_detected)} times) — not serialized")
117
+ posted_ns = [(c["post"], c["edits"]) for c in calls]
118
+ expected = [([1], []), ([], [{"n": 2, "text": "edited two"}]), ([4], [])]
119
+ if posted_ns != expected:
120
+ fail.append(f"wrong calls/order: got {posted_ns}\n expected {expected}")
121
+ labels = [c["activity_label"] for c in calls if c.get("activity_label")]
122
+ if not any(label == "posting 2/3" for label in labels):
123
+ fail.append(f"second post did not carry burst progress 2/3: labels={labels}")
124
+ if not any(label == "posting 3/3" for label in labels):
125
+ fail.append(f"third post did not carry burst progress 3/3: labels={labels}")
126
+ if not any(event == ("posting", "posting 1/3") for event in activity_events):
127
+ fail.append(f"activity never expanded first post to 1/3: events={activity_events}")
128
+ if any(c["post"] == [3] or any(e.get("n") == 3 for e in c["edits"]) for c in calls):
129
+ fail.append("rejected card #3 was posted")
130
+ with app._review_lock:
131
+ if app._posts_outstanding != 0:
132
+ fail.append(f"_posts_outstanding leaked: {app._posts_outstanding}")
133
+ if app._review_active:
134
+ fail.append("_review_active stuck true after drain + panel closed")
135
+
136
+ if fail:
137
+ print("FAIL:")
138
+ for f in fail:
139
+ print(" -", f)
140
+ sys.exit(1)
141
+ print("PASS: 3 posts, serialized, FIFO order, #3 skipped, flags settled idle.")
142
+ print(" calls:", posted_ns)