@m13v/s4l 1.7.6-rc.13 → 1.7.6-rc.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "1.7.6-rc.13",
3
- "installedAt": "2026-07-17T20:44:58.166Z"
2
+ "version": "1.7.6-rc.15",
3
+ "installedAt": "2026-07-17T21:35:54.890Z"
4
4
  }
package/mcp/manifest.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "dxt_version": "0.1",
3
3
  "name": "social-autoposter",
4
4
  "display_name": "S4L",
5
- "version": "1.7.6-rc.13",
5
+ "version": "1.7.6-rc.15",
6
6
  "description": "Draft, review, approve, and autopilot X/Twitter posts.",
7
7
  "long_description": "## **⚠️ The disclaimer above is generic Claude boilerplate.** Anthropic shows the same warning on every plugin regardless of what it does; any plugin has the same level of access as any app you download from the internet.\n\nS4L is an open source product developed by Mediar.ai Incorporated, a VC-backed San Francisco-based startup.\n\nTo get started:\n\n1\\. Copy this prompt: **Set me up on S4L plugin end to end**\n\n2\\. Quit with CMD+Q, reopen Claude, paste into a new chat.\n\nWhat happens next:\n\n* About every 5 minutes S4L scans X for posts that match your topics and drafts replies in your voice.\n* Drafts show up as review cards, usually the first within a few minutes. Nothing is posted automatically; you approve each one.\n* Posting autopilot stays off until you explicitly turn it on.",
8
8
  "author": {
@@ -331,7 +331,7 @@ def _truncate(s, n=320):
331
331
  return s if len(s) <= n else s[: n - 1] + "…"
332
332
 
333
333
 
334
- def _set_thread_text(thread_tv, text, link_url, *, font_size=12):
334
+ def _set_thread_text(thread_tv, text, link_url, *, font_size=12, title=None):
335
335
  """Set the thread-quote text on `thread_tv`: the FULL text, never
336
336
  trimmed (2026-07-16 user direction -- the box scrolls instead when it
337
337
  doesn't fit; see its NSScrollView wrapper in _render()). A leading
@@ -340,8 +340,15 @@ def _set_thread_text(thread_tv, text, link_url, *, font_size=12):
340
340
  of how long the thread is or where the box happens to be scrolled to
341
341
  (the old trailing-link placement could end up scrolled out of view, or
342
342
  -- before scrolling existed at all -- get silently truncated off the
343
- end; see the superseded _fit_thread_body this replaced)."""
343
+ end; see the superseded _fit_thread_body this replaced).
344
+
345
+ `title` (2026-07-17 user direction, Reddit): when given, it's drawn BOLD
346
+ on its own line ahead of `text`, so a Reddit card shows the post title in
347
+ bold with the selftext body beneath it in the same quote box. Twitter and
348
+ title-only Reddit threads pass title=None and render `text` alone,
349
+ exactly as before."""
344
350
  text = (text or "").strip()
351
+ title = (title or "").strip()
345
352
  body = NSMutableAttributedString.alloc().initWithString_attributes_("", {})
346
353
  if link_url:
347
354
  body.appendAttributedString_(
@@ -356,6 +363,19 @@ def _set_thread_text(thread_tv, text, link_url, *, font_size=12):
356
363
  },
357
364
  )
358
365
  )
366
+ if title:
367
+ # Bold title on its own line; a trailing newline separates it from the
368
+ # body only when there IS a body (a title with no selftext shouldn't
369
+ # leave a dangling blank line).
370
+ body.appendAttributedString_(
371
+ NSAttributedString.alloc().initWithString_attributes_(
372
+ title + ("\n" if text else ""),
373
+ {
374
+ NSFontAttributeName: NSFont.boldSystemFontOfSize_(font_size),
375
+ NSForegroundColorAttributeName: NSColor.labelColor(),
376
+ },
377
+ )
378
+ )
359
379
  body.appendAttributedString_(
360
380
  NSAttributedString.alloc().initWithString_attributes_(
361
381
  text,
@@ -1598,7 +1618,19 @@ class _ReviewController(NSObject):
1598
1618
  pass
1599
1619
  if thread_url:
1600
1620
  thread_tv.setDelegate_(self)
1601
- _set_thread_text(thread_tv, thread_en or d.get("thread_text"), thread_url)
1621
+ # Reddit (2026-07-17 user direction): the thread has a TITLE and a
1622
+ # BODY (selftext). Render the title bold with the body beneath it in
1623
+ # this same quote box. thread_text carries the title (see
1624
+ # merge_review_queue._reddit_plan_to_candidates); thread_selftext is
1625
+ # the body, empty for link posts / title-only threads (then this falls
1626
+ # through to the plain title-only render). Twitter has no selftext, so
1627
+ # it always takes the else branch and renders exactly as before.
1628
+ _thread_title = thread_en or d.get("thread_text")
1629
+ _thread_body = (d.get("thread_selftext") or "").strip() if _plat == "reddit" else ""
1630
+ if _thread_body:
1631
+ _set_thread_text(thread_tv, _thread_body, thread_url, title=_thread_title)
1632
+ else:
1633
+ _set_thread_text(thread_tv, _thread_title, thread_url)
1602
1634
  thread_scroll.setDocumentView_(thread_tv)
1603
1635
  content.addSubview_(thread_scroll)
1604
1636
  # Reply heading — bold. A concise "project/lane · viral N" tag rides
package/mcp/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m13v/s4l-mcp",
3
- "version": "1.7.6-rc.13",
3
+ "version": "1.7.6-rc.15",
4
4
  "private": true,
5
5
  "description": "Desktop MCP client for social-autoposter (X/Twitter rail): manual draft/review/approve loop, autopilot control, and stats. Thin wrapper over the existing pipeline scripts.",
6
6
  "license": "MIT",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m13v/s4l",
3
- "version": "1.7.6-rc.13",
3
+ "version": "1.7.6-rc.15",
4
4
  "description": "Automated social posting pipeline for Reddit, X/Twitter, LinkedIn, and Moltbook. Install as a Claude Code agent skill.",
5
5
  "bin": {
6
6
  "social-autoposter": "bin/cli.js",
@@ -229,7 +229,14 @@ def _recent_batches_not_progressing(min_batches: int = BATCH_PROGRESSION_MIN_BAT
229
229
  import http_api # noqa: E402
230
230
 
231
231
  resp = http_api.api_get("/api/v1/twitter-batches", {"list": "1", "limit": str(min_batches)})
232
- batches = (resp or {}).get("batches") or []
232
+ # http_api.api_get returns the RAW envelope {"ok":..,"data":{"batches":[..],
233
+ # "owner_host":..}} — it does NOT unwrap "data". The batches live at
234
+ # resp["data"]["batches"], never at resp["batches"]. Reading the wrong key
235
+ # made this return [] -> len<min -> False on EVERY call, so batches_stuck was
236
+ # hardwired False and the "drafting is not actually happening" stall cause
237
+ # could never be selected (missed Nhat's 07-15 phase2b-prep pileup entirely).
238
+ # Do NOT collapse back to resp.get("batches").
239
+ batches = ((resp or {}).get("data") or {}).get("batches") or []
233
240
  if len(batches) < min_batches:
234
241
  return False # not enough history yet to conclude a stall
235
242
  return all((b or {}).get("current_phase") not in BATCH_PROGRESSED_PHASES for b in batches)
@@ -168,6 +168,14 @@ def _reddit_plan_to_candidates(plan: dict) -> list:
168
168
  "thread_url": d.get("thread_url"),
169
169
  "thread_author": d.get("thread_author"),
170
170
  "thread_text": d.get("thread_title") or "",
171
+ # Reddit threads carry a title (thread_text, above) AND a body
172
+ # (selftext). Both matter to a reviewer: the title alone often
173
+ # can't be judged without the post body it heads. The card renders
174
+ # the title bold and the selftext beneath it in the same quote box
175
+ # (2026-07-17 user direction). Link posts / title-only threads have
176
+ # an empty selftext, so the card falls back to title-only. Decisions
177
+ # carry it as `selftext` (from the candidate) or `thread_selftext`.
178
+ "thread_selftext": d.get("selftext") or d.get("thread_selftext") or "",
171
179
  "matched_project": plan.get("project_name"),
172
180
  "reply_text": d.get("text"),
173
181
  "engagement_style": d.get("engagement_style"),
@@ -106,12 +106,20 @@ def _venue(post: dict) -> str | None:
106
106
  return None
107
107
 
108
108
 
109
- def _removed_event(post: dict) -> dict:
109
+ def _removed_event(post: dict, venue_count: int = 0) -> dict:
110
110
  venue = _venue(post)
111
111
  is_own_thread = bool(post.get("thread_url")) and post.get("thread_url") == post.get("our_url")
112
112
  kind = "top-level post" if is_own_thread else "comment/reply"
113
113
  posted = str(post.get("posted_at") or "")[:10]
114
114
  detected = str(post.get("status_checked_at") or "")[:10]
115
+ # venue_count = how many of our posts were removed in this same venue inside
116
+ # the sweep window. Surfaces clustering (one venue removing many of our
117
+ # posts is a venue-fit lesson, not N unrelated content lessons) directly in
118
+ # the note the digest reads.
119
+ cluster = ""
120
+ if venue and venue_count > 1:
121
+ cluster = (f" This is 1 of {venue_count} of our posts removed in"
122
+ f" {venue} in the recent window (a venue-level pattern).")
115
123
  note = (
116
124
  f"Platform moderation strike: our {post.get('platform')} {kind}"
117
125
  f"{' in ' + venue if venue else ''} is now status='{post.get('status')}'"
@@ -120,6 +128,7 @@ def _removed_event(post: dict) -> dict:
120
128
  f" account {post.get('our_account')}."
121
129
  f" Machine-derived signal, not a human card decision: a moderator or"
122
130
  f" platform filter judged this content unwelcome in that venue."
131
+ f"{cluster}"
123
132
  )
124
133
  return {
125
134
  "event_uuid": str(uuid.uuid5(EVENT_NS, f"platform_removed:post:{post['id']}")),
@@ -156,7 +165,7 @@ def _ban_event(entry: dict) -> dict:
156
165
  }
157
166
 
158
167
 
159
- def collect_events(window_days: int) -> tuple[list[dict], int, int]:
168
+ def collect_events(window_days: int, dry_run: bool = False) -> tuple[list[dict], int, int]:
160
169
  """Returns (events, removed_count, ban_count)."""
161
170
  cfg = _load_config()
162
171
  local_projects = {p.get("name") for p in (cfg.get("projects") or []) if p.get("name")}
@@ -170,7 +179,7 @@ def collect_events(window_days: int) -> tuple[list[dict], int, int]:
170
179
  })
171
180
  posts = ((resp or {}).get("data") or {}).get("posts") or []
172
181
 
173
- removed_events = []
182
+ fresh = []
174
183
  for p in posts:
175
184
  if p.get("project_name") not in local_projects:
176
185
  continue
@@ -179,7 +188,47 @@ def collect_events(window_days: int) -> tuple[list[dict], int, int]:
179
188
  detected = _parse_ts(p.get("status_checked_at"))
180
189
  if detected is None or detected < cutoff:
181
190
  continue
182
- removed_events.append(_removed_event(p))
191
+ fresh.append(p)
192
+
193
+ # Per-venue removal counts (clustering signal surfaced in each event note).
194
+ venue_counts: dict[str, int] = {}
195
+ for p in fresh:
196
+ v = _venue(p)
197
+ if v:
198
+ venue_counts[v] = venue_counts.get(v, 0) + 1
199
+ removed_events = [_removed_event(p, venue_counts.get(_venue(p) or "", 0))
200
+ for p in fresh]
201
+
202
+ # Deterministic community-ban detection (2026-07-17): before reading the
203
+ # ban denylist, inspect the logged-in ban state of every reddit subreddit
204
+ # that struck us this window and persist newly-confirmed bans (reason
205
+ # 'account_blocked_in_sub'). This is what turns a pile of per-post removals
206
+ # into a single platform_banned learning signal, and upgrades weak manual
207
+ # denylist entries so their ban event can finally fire. Read-only browser
208
+ # attach; no-ops gracefully (returns unknown) on installs without a live
209
+ # Reddit session, and can be disabled with S4L_STRIKE_BAN_CHECK=0.
210
+ if os.environ.get("S4L_STRIKE_BAN_CHECK", "1") not in ("0", "false", "no"):
211
+ reddit_subs = sorted({
212
+ re.search(r"reddit\.com/r/([^/]+)/", (p.get("thread_url") or p.get("our_url") or "")).group(1)
213
+ for p in fresh
214
+ if re.search(r"reddit\.com/r/([^/]+)/", (p.get("thread_url") or p.get("our_url") or ""))
215
+ })
216
+ if reddit_subs:
217
+ try:
218
+ import reddit_ban_check
219
+ if dry_run:
220
+ # read-only: detect and report, never persist
221
+ st = reddit_ban_check.banned_state(reddit_subs)
222
+ banned = [s for s, v in st.items() if v is True]
223
+ log(f"ban_check (dry run) subs={len(reddit_subs)} banned={banned}")
224
+ else:
225
+ res = reddit_ban_check.record_confirmed_bans(reddit_subs)
226
+ log(f"ban_check subs={len(reddit_subs)} banned={len(res.get('banned') or [])}"
227
+ f" recorded={res.get('recorded')} upgraded={res.get('upgraded')}")
228
+ if res.get("recorded") or res.get("upgraded"):
229
+ cfg = _load_config() # reload so new ban entries below are seen
230
+ except Exception as e:
231
+ log(f"ban_check_error (non-fatal): {e}")
183
232
 
184
233
  ban_events = []
185
234
  for entry in ((cfg.get("subreddit_bans") or {}).get("comment_blocked") or []):
@@ -194,7 +243,7 @@ def collect_events(window_days: int) -> tuple[list[dict], int, int]:
194
243
 
195
244
 
196
245
  def sweep(window_days: int = 14, dry_run: bool = False) -> dict:
197
- events, n_removed, n_banned = collect_events(window_days)
246
+ events, n_removed, n_banned = collect_events(window_days, dry_run=dry_run)
198
247
  inserted = 0
199
248
  duplicates = 0
200
249
  if dry_run:
@@ -0,0 +1,38 @@
1
+ # Scratch preview: renders one Reddit review card with a title + selftext,
2
+ # to visually verify the bold-title-over-body thread box (2026-07-17).
3
+ import os, sys
4
+ os.environ["S4L_STATE_DIR"] = "/tmp/s4l-card-preview-state"
5
+ os.makedirs("/tmp/s4l-card-preview-state", exist_ok=True)
6
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "mcp", "menubar"))
7
+ from AppKit import NSApplication
8
+ import s4l_card
9
+
10
+ drafts = [{
11
+ "n": 1,
12
+ "platform": "reddit",
13
+ "thread_author": "SureThing07",
14
+ "thread_text": "Im fu**ed. I've realised the whole game is a placeholder",
15
+ "thread_selftext": (
16
+ "I've been building this solo for eight months and I just opened the "
17
+ "codebase after a week off. Half of it is stubs I meant to come back "
18
+ "to. The save system? Placeholder. The combat? Placeholder wired to a "
19
+ "real animation so it LOOKED done. I genuinely can't tell how much of "
20
+ "this game actually exists anymore. How do you all deal with this?"
21
+ ),
22
+ "reply_text": (
23
+ "my rule of thumb from solo projects: by month six, maybe a third of "
24
+ "the code is actually load-bearing and the rest is scaffolding you "
25
+ "forgot you wrote."
26
+ ),
27
+ "thread_url": "https://www.reddit.com/r/gamedev/comments/abc123/placeholder/",
28
+ "candidate_id": "rd-preview1",
29
+ "project": "podlog",
30
+ "engagement_style": "empathize",
31
+ "search_topic": "solo dev burnout",
32
+ "stats": {"replies": 34, "author_followers": 0,
33
+ "tweet_posted_at": "2026-07-17T14:00:00Z"},
34
+ }]
35
+ app = NSApplication.sharedApplication()
36
+ s4l_card.present_review(drafts, on_decision=lambda d: None,
37
+ on_complete=lambda ds: app.terminate_(None))
38
+ app.run()
@@ -0,0 +1,220 @@
1
+ #!/usr/bin/env python3
2
+ """Deterministic Reddit community-ban detection.
3
+
4
+ When a post is discovered removed, "was that one post moderated, or is our
5
+ account BANNED from the whole community?" is not answerable from the removal
6
+ object itself: a mod-removed comment scrubs to author=[deleted]/body=[removed]
7
+ with no reason, and a ban does NOT retroactively delete our older comments, so
8
+ "our comments are still visible there" is NOT evidence we are un-banned.
9
+
10
+ The authoritative signal is the logged-in view of `/r/<sub>/about.json`, whose
11
+ `user_is_banned` field is populated per the requesting account's session. We
12
+ already hold a logged-in Reddit browser (the reddit-agent / reddit-harness
13
+ Chrome); this module CDP-attaches to it read-only (one navigation, N same-origin
14
+ fetches, no clicks/posts) and reads that field. Mirrors the read-only carve-out
15
+ pattern used elsewhere (linkedin_browser sidebar pre-check).
16
+
17
+ Graceful degradation: if no Reddit session is reachable (customer install with
18
+ no browser, wrong account, session logged out), every checker returns None and
19
+ callers MUST treat None as "unknown, do nothing" — never as "not banned".
20
+
21
+ Usage:
22
+ python3 scripts/reddit_ban_check.py ClaudeAI theravada rust # ad-hoc
23
+ python3 scripts/reddit_ban_check.py --record --project podlog r/X # detect+persist
24
+
25
+ Public API:
26
+ banned_state(subs) -> {sub_lower: True|False|None}
27
+ is_banned(sub) -> True|False|None
28
+ record_confirmed_bans(subs, project=None)
29
+ -> {"banned": [...], "recorded": [...], "upgraded": [...], "unknown": [...]}
30
+ """
31
+ from __future__ import annotations
32
+
33
+ import json
34
+ import os
35
+ import re
36
+ import sys
37
+ from pathlib import Path
38
+
39
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
40
+
41
+ # about.json JS: fetch each sub's logged-in metadata, return the ban flags.
42
+ _ABOUT_JS = r"""
43
+ async (subs) => {
44
+ const out = {};
45
+ for (const s of subs) {
46
+ try {
47
+ const r = await fetch(`https://old.reddit.com/r/${s}/about.json`,
48
+ {headers:{'Accept':'application/json'}, credentials:'include'});
49
+ if (!r.ok) { out[s] = {http: r.status}; continue; }
50
+ const d = (await r.json()).data || {};
51
+ out[s] = { banned: d.user_is_banned === true,
52
+ muted: d.user_is_muted === true,
53
+ has_flag: ('user_is_banned' in d) };
54
+ } catch (e) { out[s] = {err: String(e).slice(0,80)}; }
55
+ await new Promise(x => setTimeout(x, 250));
56
+ }
57
+ return out;
58
+ }
59
+ """
60
+
61
+
62
+ def _norm(sub: str) -> str:
63
+ s = (sub or "").strip()
64
+ m = re.search(r"/r/([^/]+)", s)
65
+ if m:
66
+ s = m.group(1)
67
+ return s.strip().strip("/").lower()
68
+
69
+
70
+ def _reddit_page(pw):
71
+ """Attach to the running logged-in Reddit Chrome and return (browser, page).
72
+
73
+ Prefers REDDIT_CDP_URL, else discovers the reddit-agent port via
74
+ reddit_browser.find_reddit_cdp_port. Opens a DEDICATED page so we never
75
+ fight the live harness tab. Returns (None, None) if unreachable."""
76
+ import urllib.request
77
+
78
+ ws = None
79
+ cdp_url = (os.environ.get("REDDIT_CDP_URL") or "").strip()
80
+ if cdp_url:
81
+ ws = cdp_url
82
+ else:
83
+ try:
84
+ from reddit_browser import find_reddit_cdp_port
85
+ port = find_reddit_cdp_port()
86
+ if not port:
87
+ return None, None
88
+ with urllib.request.urlopen(
89
+ f"http://127.0.0.1:{port}/json/version", timeout=3
90
+ ) as r:
91
+ ws = json.load(r)["webSocketDebuggerUrl"]
92
+ except Exception:
93
+ return None, None
94
+ try:
95
+ browser = pw.chromium.connect_over_cdp(ws)
96
+ ctx = browser.contexts[0] if browser.contexts else browser.new_context()
97
+ page = ctx.new_page()
98
+ page.set_default_timeout(20000)
99
+ return browser, page
100
+ except Exception:
101
+ return None, None
102
+
103
+
104
+ def banned_state(subs) -> dict:
105
+ """Return {sub_lower: True|False|None} for each sub. None = unknown
106
+ (no session / fetch failed): callers MUST NOT treat None as un-banned."""
107
+ norm = []
108
+ for s in subs:
109
+ n = _norm(s)
110
+ if n and n not in norm:
111
+ norm.append(n)
112
+ result = {n: None for n in norm}
113
+ if not norm:
114
+ return result
115
+ try:
116
+ from playwright.sync_api import sync_playwright
117
+ except Exception:
118
+ return result
119
+ with sync_playwright() as pw:
120
+ browser, page = _reddit_page(pw)
121
+ if page is None:
122
+ return result
123
+ try:
124
+ page.goto("https://old.reddit.com/", wait_until="domcontentloaded",
125
+ timeout=20000)
126
+ # confirm we actually have a logged-in session; a logged-out view
127
+ # reports user_is_banned=false for everything, a false negative.
128
+ me = page.evaluate(
129
+ "async () => { try { const r = await fetch("
130
+ "'https://old.reddit.com/api/me.json', {credentials:'include'}); "
131
+ "const d = await r.json(); return (d.data && d.data.name) || null; } "
132
+ "catch(e){ return null; } }"
133
+ )
134
+ if not me:
135
+ return result # not logged in -> all unknown
136
+ raw = page.evaluate(_ABOUT_JS, norm)
137
+ except Exception:
138
+ return result
139
+ finally:
140
+ try:
141
+ page.close()
142
+ except Exception:
143
+ pass
144
+ for n in norm:
145
+ v = (raw or {}).get(n) or {}
146
+ if v.get("has_flag"):
147
+ result[n] = bool(v.get("banned"))
148
+ else:
149
+ result[n] = None # http error / missing field -> unknown
150
+ return result
151
+
152
+
153
+ def is_banned(sub) -> "bool|None":
154
+ return banned_state([sub]).get(_norm(sub))
155
+
156
+
157
+ def record_confirmed_bans(subs, project=None) -> dict:
158
+ """Check each sub and persist newly-confirmed community bans into
159
+ config.json subreddit_bans.comment_blocked with reason
160
+ 'account_blocked_in_sub' (the reason platform_strike_events reads to emit
161
+ platform_banned learning events). Upgrades pre-existing weak entries
162
+ (reason=None) to the confirmed reason. Never records on None (unknown)."""
163
+ from config import config_path
164
+ from post_reddit import _make_ban_entry, _ban_entry_sub
165
+
166
+ state = banned_state(subs)
167
+ banned = [s for s, v in state.items() if v is True]
168
+ unknown = [s for s, v in state.items() if v is None]
169
+ recorded, upgraded = [], []
170
+ if not banned:
171
+ return {"banned": [], "recorded": [], "upgraded": [], "unknown": unknown}
172
+
173
+ path = config_path()
174
+ with open(path) as f:
175
+ cfg = json.load(f)
176
+ bans = cfg.setdefault("subreddit_bans", {})
177
+ blocked = bans.setdefault("comment_blocked", [])
178
+ by_sub = {(_ban_entry_sub(e) or "").lower(): e for e in blocked}
179
+
180
+ for sub in banned:
181
+ entry = by_sub.get(sub)
182
+ if entry is None:
183
+ blocked.append(_make_ban_entry(sub, "account_blocked_in_sub", project))
184
+ recorded.append(sub)
185
+ elif entry.get("reason") != "account_blocked_in_sub":
186
+ # upgrade a weak/manual entry so the digest ban event can fire
187
+ from datetime import datetime, timezone
188
+ entry["reason"] = "account_blocked_in_sub"
189
+ if not entry.get("added_at"):
190
+ entry["added_at"] = datetime.now(timezone.utc).strftime(
191
+ "%Y-%m-%dT%H:%M:%SZ")
192
+ entry.setdefault("noticed_by_project", project)
193
+ upgraded.append(sub)
194
+
195
+ if recorded or upgraded:
196
+ blocked.sort(key=lambda e: (_ban_entry_sub(e) or ""))
197
+ with open(path, "w") as f:
198
+ json.dump(cfg, f, indent=2)
199
+ f.write("\n")
200
+ return {"banned": banned, "recorded": recorded, "upgraded": upgraded,
201
+ "unknown": unknown}
202
+
203
+
204
+ def main() -> int:
205
+ import argparse
206
+ ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
207
+ ap.add_argument("subs", nargs="+", help="subreddit names or /r/x URLs")
208
+ ap.add_argument("--record", action="store_true",
209
+ help="persist confirmed bans to config.json")
210
+ ap.add_argument("--project", default=None, help="audit breadcrumb")
211
+ args = ap.parse_args()
212
+ if args.record:
213
+ print(json.dumps(record_confirmed_bans(args.subs, args.project), indent=2))
214
+ else:
215
+ print(json.dumps(banned_state(args.subs), indent=2))
216
+ return 0
217
+
218
+
219
+ if __name__ == "__main__":
220
+ sys.exit(main())
@@ -384,6 +384,28 @@ NODE_RUNTIME_PLATFORM="darwin-arm64"
384
384
  NODE_VENDOR_DIR="$MCP_DIR/vendor/node-$NODE_RUNTIME_PLATFORM"
385
385
  NODE_VENDOR_BIN="$NODE_VENDOR_DIR/bin/node"
386
386
  NODE_CACHE_DIR="$REPO_ROOT/.cache/node-runtime"
387
+ # Can THIS build host execute the vendored target binary? The .mcpb always
388
+ # vendors darwin-arm64 (that is where it runs), but the release itself is cut
389
+ # from CI on linux-x64. Executing a darwin-arm64 node on a linux-x64 runner is
390
+ # an `Exec format error` (exit 126) -- what broke every rc from 1.7.6-rc.11 on.
391
+ # Run the exec-based `--version` sanity checks ONLY when the host os/arch matches
392
+ # the vendored target; otherwise verify by presence + executable bit, which is
393
+ # all a cross-build can honestly assert.
394
+ case "$(uname -s)" in
395
+ Darwin) HOST_OS="darwin" ;;
396
+ Linux) HOST_OS="linux" ;;
397
+ *) HOST_OS="unknown" ;;
398
+ esac
399
+ case "$(uname -m)" in
400
+ arm64|aarch64) HOST_ARCH="arm64" ;;
401
+ x86_64|amd64) HOST_ARCH="x64" ;;
402
+ *) HOST_ARCH="unknown" ;;
403
+ esac
404
+ if [[ "$HOST_OS-$HOST_ARCH" == "$NODE_RUNTIME_PLATFORM" ]]; then
405
+ HOST_CAN_EXEC_VENDORED_NODE=1
406
+ else
407
+ HOST_CAN_EXEC_VENDORED_NODE=0
408
+ fi
387
409
  say "Vendoring Node $NODE_RUNTIME_VERSION ($NODE_RUNTIME_PLATFORM) for the .mcpb"
388
410
  if [[ ! -x "$NODE_VENDOR_BIN" ]]; then
389
411
  NODE_TARBALL="node-$NODE_RUNTIME_VERSION-$NODE_RUNTIME_PLATFORM.tar.gz"
@@ -406,9 +428,13 @@ if [[ ! -x "$NODE_VENDOR_BIN" ]]; then
406
428
  chmod +x "$NODE_VENDOR_BIN"
407
429
  fi
408
430
  [[ -x "$NODE_VENDOR_BIN" ]] || die "vendored node binary missing/not executable at $NODE_VENDOR_BIN"
409
- VENDORED_NODE_VER="$("$NODE_VENDOR_BIN" --version)"
410
- [[ "$VENDORED_NODE_VER" == "$NODE_RUNTIME_VERSION" ]] || die "vendored node reports $VENDORED_NODE_VER, expected $NODE_RUNTIME_VERSION"
411
- echo " vendored node: $VENDORED_NODE_VER at mcp/vendor/node-$NODE_RUNTIME_PLATFORM/bin/node ($(du -h "$NODE_VENDOR_BIN" | cut -f1))"
431
+ if [[ "$HOST_CAN_EXEC_VENDORED_NODE" == "1" ]]; then
432
+ VENDORED_NODE_VER="$("$NODE_VENDOR_BIN" --version)"
433
+ [[ "$VENDORED_NODE_VER" == "$NODE_RUNTIME_VERSION" ]] || die "vendored node reports $VENDORED_NODE_VER, expected $NODE_RUNTIME_VERSION"
434
+ echo " vendored node: $VENDORED_NODE_VER at mcp/vendor/node-$NODE_RUNTIME_PLATFORM/bin/node ($(du -h "$NODE_VENDOR_BIN" | cut -f1))"
435
+ else
436
+ echo " vendored node: present at mcp/vendor/node-$NODE_RUNTIME_PLATFORM/bin/node ($(du -h "$NODE_VENDOR_BIN" | cut -f1)); --version self-check skipped (host $HOST_OS-$HOST_ARCH cannot exec $NODE_RUNTIME_PLATFORM)"
437
+ fi
412
438
 
413
439
  # ---- 4. Pack the .mcpb ------------------------------------------------------
414
440
  say "Packing $BUNDLE"
@@ -474,10 +500,15 @@ VENDOR_CHECK_DIR="$(mktemp -d)"
474
500
  unzip -q -o "$BUNDLE" "vendor/node-$NODE_RUNTIME_PLATFORM/bin/node" -d "$VENDOR_CHECK_DIR"
475
501
  VENDOR_CHECK_BIN="$VENDOR_CHECK_DIR/vendor/node-$NODE_RUNTIME_PLATFORM/bin/node"
476
502
  [[ -x "$VENDOR_CHECK_BIN" ]] || die "vendored node in bundle lost its executable bit"
477
- VENDOR_CHECK_VER="$("$VENDOR_CHECK_BIN" --version)"
478
- [[ "$VENDOR_CHECK_VER" == "$NODE_RUNTIME_VERSION" ]] || die "vendored node in bundle reports $VENDOR_CHECK_VER, expected $NODE_RUNTIME_VERSION"
479
- rm -rf "$VENDOR_CHECK_DIR"
480
- echo " vendored node runs from bundle: $VENDOR_CHECK_VER ok"
503
+ if [[ "$HOST_CAN_EXEC_VENDORED_NODE" == "1" ]]; then
504
+ VENDOR_CHECK_VER="$("$VENDOR_CHECK_BIN" --version)"
505
+ [[ "$VENDOR_CHECK_VER" == "$NODE_RUNTIME_VERSION" ]] || die "vendored node in bundle reports $VENDOR_CHECK_VER, expected $NODE_RUNTIME_VERSION"
506
+ rm -rf "$VENDOR_CHECK_DIR"
507
+ echo " vendored node runs from bundle: $VENDOR_CHECK_VER ok"
508
+ else
509
+ rm -rf "$VENDOR_CHECK_DIR"
510
+ echo " vendored node survives bundle round-trip with exec bit intact; --version run skipped (host $HOST_OS-$HOST_ARCH cannot exec $NODE_RUNTIME_PLATFORM)"
511
+ fi
481
512
 
482
513
  if [[ "$DO_RELEASE" == "0" ]]; then
483
514
  say "Done (--no-release). Bundle ready at: $BUNDLE"
@@ -455,10 +455,93 @@ def _owner_strike_count(owner, days=90):
455
455
  return (live_count, raw_count)
456
456
 
457
457
 
458
- def _format_subject(post, repo_state=None):
458
+ def _subreddit_from_url(url):
459
+ if not url:
460
+ return None
461
+ m = re.search(r"reddit\.com/r/([^/]+)", url)
462
+ return m.group(1).lower() if m else None
463
+
464
+
465
+ def _subreddit_strike_count(sub, days=90):
466
+ """How many of our reddit posts in r/<sub> were moderated (removed/deleted)
467
+ in the last `days` days. The reddit analogue of _owner_strike_count: turns a
468
+ single strike email into 'this is the Nth removal in this community', which
469
+ is the clustering signal that separates a chronic bad-fit venue from a
470
+ one-off removal. Returns 0 on any lookup failure (never blocks the email)."""
471
+ if not sub:
472
+ return 0
473
+ try:
474
+ resp = api_get("/api/v1/posts/thread-urls", query={
475
+ "platform": "reddit", "moderated_within_days": int(days),
476
+ })
477
+ except Exception:
478
+ return 0
479
+ urls = (resp.get("data") or {}).get("thread_urls") or []
480
+ return sum(1 for u in urls if _subreddit_from_url(u) == sub)
481
+
482
+
483
+ def _recorded_ban(sub, config, account):
484
+ """True if r/<sub> is already on the config.json comment_blocked denylist
485
+ with reason 'account_blocked_in_sub' for this account (the recorded output
486
+ of the deterministic ban check). Account-scoped, matching
487
+ reddit_tools._load_comment_blocked_subs semantics."""
488
+ if not sub:
489
+ return False
490
+ acct = (account or "").lower()
491
+ for entry in ((config.get("subreddit_bans") or {}).get("comment_blocked") or []):
492
+ if not isinstance(entry, dict):
493
+ continue
494
+ if (entry.get("sub") or "").strip().lower() != sub:
495
+ continue
496
+ if entry.get("reason") != "account_blocked_in_sub":
497
+ continue
498
+ ent_acct = (entry.get("account") or "").lower()
499
+ if ent_acct and acct and ent_acct != acct:
500
+ continue
501
+ return True
502
+ return False
503
+
504
+
505
+ def _reddit_ban_verdict(subs, config, account):
506
+ """Return {sub: 'banned'|'not_banned'|'unknown'} for reddit strike subs.
507
+
508
+ Prefers a single live deterministic check (reddit_ban_check.banned_state,
509
+ one browser attach for the whole sweep, read-only) and falls back to the
510
+ recorded config denylist when no session is reachable or the check is
511
+ disabled via S4L_STRIKE_BAN_CHECK=0. A recorded ban is authoritative even
512
+ when the live check says unknown (session down) so we never downgrade a
513
+ known ban to 'single-post removal'."""
514
+ verdict = {}
515
+ live = {}
516
+ if subs and os.environ.get("S4L_STRIKE_BAN_CHECK", "1") not in ("0", "false", "no"):
517
+ try:
518
+ import reddit_ban_check
519
+ live = reddit_ban_check.banned_state(list(subs)) or {}
520
+ except Exception as e:
521
+ print(f"[strike_alert] reddit_ban_check unavailable (non-fatal): {e}",
522
+ file=sys.stderr)
523
+ for sub in subs:
524
+ lv = live.get(sub)
525
+ if lv is True:
526
+ verdict[sub] = "banned"
527
+ elif _recorded_ban(sub, config, account):
528
+ verdict[sub] = "banned"
529
+ elif lv is False:
530
+ verdict[sub] = "not_banned"
531
+ else:
532
+ verdict[sub] = "unknown"
533
+ return verdict
534
+
535
+
536
+ def _format_subject(post, repo_state=None, sub_ban=None):
459
537
  platform = post["platform"] or "?"
460
538
  status = post["status"] or "?"
461
539
  tag = "STRIKE"
540
+ if platform == "reddit" and sub_ban == "banned":
541
+ # Account is banned from the whole community, not just this post
542
+ # removed. Loud subject tag so a ban is visually distinct from the
543
+ # far more common single-post removal.
544
+ tag = "STRIKE-BANNED"
462
545
  if platform == "github" and repo_state == "repo_gone":
463
546
  # Owner nuked the whole repo. Not a moderation strike against us.
464
547
  status = "repo-deleted"
@@ -488,7 +571,7 @@ def _ts(v):
488
571
  return v.isoformat() if hasattr(v, "isoformat") else str(v)
489
572
 
490
573
 
491
- def _format_body(post, repo_state=None):
574
+ def _format_body(post, repo_state=None, sub_ban=None, sub_count=None):
492
575
  platform = post["platform"] or "?"
493
576
  status = post["status"] or "?"
494
577
  project = post["project_name"] or "(no project)"
@@ -505,6 +588,35 @@ def _format_body(post, repo_state=None):
505
588
 
506
589
  owner_block = ""
507
590
  repo_block = ""
591
+ reddit_block = ""
592
+ if platform == "reddit":
593
+ sub = _subreddit_from_url(thread_url) or _subreddit_from_url(our_url)
594
+ if sub:
595
+ n = sub_count if sub_count is not None else _subreddit_strike_count(sub)
596
+ count_line = (
597
+ f"Subreddit: r/{sub} ({n} of our post(s) moderated here in the "
598
+ f"last 90 days)\n"
599
+ )
600
+ if sub_ban == "banned":
601
+ ban_line = (
602
+ f"Ban state: ACCOUNT BANNED from r/{sub} (deterministic "
603
+ f"user_is_banned check). This is not a single-post removal: "
604
+ f"every future post here will be removed. The sub is on the "
605
+ f"comment_blocked denylist so the drafter will stop targeting "
606
+ f"it, and a platform_banned learning signal feeds the digest.\n"
607
+ )
608
+ elif sub_ban == "not_banned":
609
+ ban_line = (
610
+ f"Ban state: not banned (user_is_banned=false). This is a "
611
+ f"single-post removal; the account can still post in r/{sub}.\n"
612
+ )
613
+ else:
614
+ ban_line = (
615
+ f"Ban state: unknown (no live Reddit session for the check and "
616
+ f"not on the recorded denylist). Treated as a single-post "
617
+ f"removal until confirmed.\n"
618
+ )
619
+ reddit_block = count_line + ban_line
508
620
  if platform == "github" and thread_url:
509
621
  if repo_state == "repo_gone":
510
622
  repo_block = (
@@ -559,6 +671,7 @@ def _format_body(post, repo_state=None):
559
671
  f"Detected: {checked_at}\n"
560
672
  f"{repo_block}"
561
673
  f"{owner_block}"
674
+ f"{reddit_block}"
562
675
  f"\n"
563
676
  f"Thread: {thread_url}\n"
564
677
  f"Title: {title}\n"
@@ -567,20 +680,38 @@ def _format_body(post, repo_state=None):
567
680
  f"--- Our content ---\n"
568
681
  f"{content_preview}\n"
569
682
  f"\n"
570
- f"--- Next steps ---\n"
571
- f"1. Inspect the thread to see if the comment was deleted, hidden,\n"
572
- f" or if the whole account was blocked.\n"
573
- f"2. If the owner should be hard-blocked, add it to\n"
574
- f" config.json -> exclusions.github_repos. Owner-level entries\n"
575
- f" match all repos under that owner.\n"
576
- f"3. The auto-blocklist (github_tools._dynamic_owner_blocklist)\n"
577
- f" already covers any owner with >=2 strikes in 90 days.\n"
683
+ f"{_next_steps(platform)}"
578
684
  f"\n"
579
685
  f"To re-fire this alert: python3 scripts/strike_alert.py --post-id {post['id']}\n"
580
686
  )
581
687
  return _scrub_dashes(body)
582
688
 
583
689
 
690
+ def _next_steps(platform):
691
+ if platform == "reddit":
692
+ return (
693
+ "--- Next steps ---\n"
694
+ "1. Ban state above is deterministic (logged-in user_is_banned).\n"
695
+ " BANNED subs are auto-added to config.json subreddit_bans\n"
696
+ " .comment_blocked and feed a platform_banned learning event;\n"
697
+ " no manual action needed to stop targeting them.\n"
698
+ "2. A single-post removal is one community's judgment on the content;\n"
699
+ " the platform_removed event already teaches the digest that\n"
700
+ " pattern. Watch the 'moderated here in last 90 days' count: a\n"
701
+ " rising count on a not-yet-banned sub is an early bad-fit signal.\n"
702
+ )
703
+ return (
704
+ "--- Next steps ---\n"
705
+ "1. Inspect the thread to see if the comment was deleted, hidden,\n"
706
+ " or if the whole account was blocked.\n"
707
+ "2. If the owner should be hard-blocked, add it to\n"
708
+ " config.json -> exclusions.github_repos. Owner-level entries\n"
709
+ " match all repos under that owner.\n"
710
+ "3. The auto-blocklist (github_tools._dynamic_owner_blocklist)\n"
711
+ " already covers any owner with >=2 strikes in 90 days.\n"
712
+ )
713
+
714
+
584
715
  def _send_email(subject, body):
585
716
  msg = MIMEText(body, "plain", "utf-8")
586
717
  msg["to"] = NOTIFICATION_EMAIL
@@ -651,6 +782,19 @@ def main():
651
782
  print("[strike_alert] no pending strikes")
652
783
  return
653
784
 
785
+ # Deterministic reddit ban verdict, computed ONCE for the whole sweep (a
786
+ # single read-only browser attach for all distinct subs, not one per email).
787
+ # Falls back to the recorded denylist when no session is reachable.
788
+ reddit_subs = {
789
+ s for s in (
790
+ _subreddit_from_url(r.get("thread_url")) or _subreddit_from_url(r.get("our_url"))
791
+ for r in rows if r.get("platform") == "reddit"
792
+ ) if s
793
+ }
794
+ reddit_ban = _reddit_ban_verdict(reddit_subs, _cfg, _reddit_username)
795
+ if reddit_subs:
796
+ print(f"[strike_alert] reddit_ban_verdict {reddit_ban}", flush=True)
797
+
654
798
  sent = 0
655
799
  skipped = 0
656
800
  filtered = 0
@@ -752,8 +896,13 @@ def main():
752
896
  )
753
897
  continue
754
898
 
755
- subject = _format_subject(r, repo_state=repo_state)
756
- body = _format_body(r, repo_state=repo_state)
899
+ _sub = None
900
+ _sub_ban = None
901
+ if r["platform"] == "reddit":
902
+ _sub = _subreddit_from_url(r.get("thread_url")) or _subreddit_from_url(r.get("our_url"))
903
+ _sub_ban = reddit_ban.get(_sub)
904
+ subject = _format_subject(r, repo_state=repo_state, sub_ban=_sub_ban)
905
+ body = _format_body(r, repo_state=repo_state, sub_ban=_sub_ban)
757
906
  if args.dry_run:
758
907
  print(f"[strike_alert] DRY RUN id={r['id']}")
759
908
  print(f" subject: {subject}")