@m13v/s4l 1.7.5-rc.2 → 1.7.5-rc.21

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 (48) hide show
  1. package/mcp/dist/index.js +571 -79
  2. package/mcp/dist/panel.html +2 -3
  3. package/mcp/dist/redditAuth.js +115 -0
  4. package/mcp/dist/runtime.js +83 -2
  5. package/mcp/dist/setup.js +142 -5
  6. package/mcp/dist/telemetry.js +13 -3
  7. package/mcp/dist/version.json +2 -2
  8. package/mcp/manifest.json +1 -1
  9. package/mcp/menubar/s4l_card.py +32 -6
  10. package/mcp/menubar/s4l_menubar.py +42 -111
  11. package/mcp/menubar/s4l_state.py +54 -187
  12. package/mcp/package.json +1 -1
  13. package/mcp/shared/onboarding-ledger.cjs +63 -4
  14. package/package.json +1 -1
  15. package/scripts/autopilot_stall_watch.py +3 -6
  16. package/scripts/browser_mutex.py +316 -0
  17. package/scripts/claude_job.py +21 -0
  18. package/scripts/config.py +118 -0
  19. package/scripts/counterparty_history.py +44 -6
  20. package/scripts/engage_github.py +7 -3
  21. package/scripts/engage_reddit.py +9 -3
  22. package/scripts/engagement_signal_feedback.py +256 -0
  23. package/scripts/learned_preferences.py +37 -14
  24. package/scripts/memory_snapshot.py +45 -0
  25. package/scripts/merge_review_queue.py +67 -3
  26. package/scripts/post_reddit.py +289 -24
  27. package/scripts/reddit_browser.py +62 -63
  28. package/scripts/reddit_browser_fetch.py +32 -0
  29. package/scripts/release-mcpb.sh +17 -0
  30. package/scripts/reset-test-machine.sh +13 -7
  31. package/scripts/scan_twitter_thread_followups.py +7 -1
  32. package/scripts/schedule_state.py +1 -1
  33. package/scripts/setup_reddit_auth.py +925 -0
  34. package/scripts/snapshot.py +96 -4
  35. package/scripts/stats.py +37 -13
  36. package/scripts/test_no_silent_fallbacks.py +281 -0
  37. package/scripts/trigger_promo_lane_test.sh +91 -0
  38. package/scripts/twitter_browser.py +183 -342
  39. package/scripts/twitter_scan.py +75 -10
  40. package/scripts/watchdog_hung_runs.py +15 -9
  41. package/skill/lib/harness-common.sh +344 -0
  42. package/skill/lib/linkedin-backend.sh +61 -161
  43. package/skill/lib/reddit-backend.sh +39 -125
  44. package/skill/lib/twitter-backend.sh +51 -273
  45. package/skill/lock.sh +57 -0
  46. package/skill/run-reddit-search-launchd.sh +4 -1
  47. package/skill/run-reddit-search.sh +51 -2
  48. package/skill/run-twitter-cycle.sh +44 -21
@@ -0,0 +1,256 @@
1
+ #!/usr/bin/env python3
2
+ """engagement_signal_feedback.py — turn downstream reply engagement into two
3
+ durable, whitelist-enforced learned_preferences_global entries:
4
+
5
+ 1. Warm-signal authors: repeat, constructive, low-risk authors (matching the
6
+ same repeat_constructive_author shape reply_risk_digest.py already tags)
7
+ get promoted into `audience_prefer` so future judging treats them as a
8
+ positive signal instead of getting rediscovered from scratch every day.
9
+
10
+ 2. Style performance: engagement_style values with enough sample size are
11
+ ranked by child-reply continuation rate (the strongest observed signal
12
+ for "this reply kept the thread going"); the current leader is recorded
13
+ as a `draft_style_notes` entry so the drafting model actually defaults to
14
+ the pattern that's been working, not just whatever the digest narrates in
15
+ a one-off email.
16
+
17
+ Both write through learned_preferences.apply_mutations(), the SAME
18
+ whitelist-enforced, flock+backup+atomic-replace writer the human review-card
19
+ feedback loop uses (see scripts/learned_preferences.py). This script is a
20
+ second, independent caller of that writer, not a new writer: the whitelist
21
+ enforcement lives in apply_mutations() itself, so nothing here can touch
22
+ config.json outside audience_prefer / draft_style_notes.
23
+
24
+ All DB reads go through the s4l.ai HTTP API (http_api), never direct SQL, so
25
+ this script works unmodified on shipped customer installs, not just this
26
+ operator box. Risk-scoring reuses reply_risk_digest._skip_reason_risk_score
27
+ by import (read-only; that file is intentionally not modified here) instead
28
+ of forking a second copy of the skip-risk heuristics.
29
+
30
+ Usage:
31
+ python3 scripts/engagement_signal_feedback.py --dry-run
32
+ python3 scripts/engagement_signal_feedback.py --platform x --days 30
33
+ """
34
+ from __future__ import annotations
35
+
36
+ import argparse
37
+ import json
38
+ import os
39
+ import sys
40
+ from collections import Counter, defaultdict
41
+ from datetime import datetime, timedelta, timezone
42
+ from pathlib import Path
43
+
44
+ REPO_DIR = Path(__file__).resolve().parent.parent
45
+ SCRIPT_DIR = REPO_DIR / "scripts"
46
+ sys.path.insert(0, str(SCRIPT_DIR))
47
+
48
+ from db import load_env # noqa: E402
49
+ from http_api import api_get # noqa: E402
50
+ from learned_preferences import apply_mutations, get_global_block # noqa: E402
51
+ from reply_risk_digest import _skip_reason_risk_score # noqa: E402
52
+
53
+ DEFAULT_DAYS = 30
54
+ MIN_STYLE_SAMPLE = 10
55
+ WARM_MIN_LAST_30D = 4
56
+ WARM_MIN_CHILD_REPLIES = 1
57
+ MAX_WARM_PROMOTIONS_PER_RUN = 3
58
+ ENTRY_CHAR_LIMIT = 200 # mirrors learned_preferences.MAX_ENTRY_CHARS
59
+
60
+
61
+ def fetch_window_rows(platform: str, days: int, limit_pages: int = 20) -> list[dict]:
62
+ """Page through /api/v1/replies for the lookback window, id-cursor style
63
+ (mirrors enrich_reply_parents.py's fetch_work loop). Capped at
64
+ limit_pages * 500 rows as a sane upper bound for a 30d window."""
65
+ since = (datetime.now(timezone.utc) - timedelta(days=int(days))).isoformat()
66
+ out: list[dict] = []
67
+ before_id = None
68
+ for _ in range(limit_pages):
69
+ query = {"platform": platform, "since": since, "order_by": "id", "limit": "500"}
70
+ if before_id:
71
+ query["before_id"] = str(before_id)
72
+ resp = api_get("/api/v1/replies", query=query)
73
+ rows = (resp.get("data") or {}).get("replies") or []
74
+ if not rows:
75
+ break
76
+ out.extend(rows)
77
+ min_id = min(r["id"] for r in rows)
78
+ if before_id is not None and min_id >= before_id:
79
+ break
80
+ before_id = min_id
81
+ if len(rows) < 500:
82
+ break
83
+ return out
84
+
85
+
86
+ def compute_style_stats(rows: list[dict]) -> list[dict]:
87
+ by_style: dict[str, dict] = defaultdict(lambda: {"n": 0, "child_replies": 0, "upvotes": 0})
88
+ for r in rows:
89
+ if r.get("status") != "replied":
90
+ continue
91
+ style = r.get("engagement_style")
92
+ if not style:
93
+ continue
94
+ s = by_style[style]
95
+ s["n"] += 1
96
+ s["child_replies"] += int(r.get("comments_count") or 0)
97
+ s["upvotes"] += int(r.get("upvotes") or 0)
98
+
99
+ ranked = []
100
+ for style, s in by_style.items():
101
+ if s["n"] < MIN_STYLE_SAMPLE:
102
+ continue
103
+ ranked.append({
104
+ "style": style,
105
+ "n": s["n"],
106
+ "child_reply_rate": s["child_replies"] / s["n"],
107
+ "upvote_rate": s["upvotes"] / s["n"],
108
+ })
109
+ ranked.sort(key=lambda x: x["child_reply_rate"], reverse=True)
110
+ return ranked
111
+
112
+
113
+ def compute_warm_authors(rows: list[dict]) -> list[dict]:
114
+ by_author: dict[str, dict] = defaultdict(lambda: {
115
+ "n": 0, "replied": 0, "riskish_skips": 0, "child_replies": 0,
116
+ "upvotes": 0, "projects": Counter(),
117
+ })
118
+ for r in rows:
119
+ handle = (r.get("their_author") or "").strip()
120
+ if not handle:
121
+ continue
122
+ a = by_author[handle]
123
+ a["n"] += 1
124
+ if r.get("status") == "replied":
125
+ a["replied"] += 1
126
+ if _skip_reason_risk_score(r.get("skip_reason") or "") >= 4:
127
+ a["riskish_skips"] += 1
128
+ a["child_replies"] += int(r.get("comments_count") or 0)
129
+ a["upvotes"] += int(r.get("upvotes") or 0)
130
+ if r.get("project_name"):
131
+ a["projects"][r["project_name"]] += 1
132
+
133
+ warm = []
134
+ for handle, a in by_author.items():
135
+ if a["n"] < WARM_MIN_LAST_30D:
136
+ continue
137
+ if a["riskish_skips"] > 0:
138
+ continue
139
+ if a["child_replies"] < WARM_MIN_CHILD_REPLIES:
140
+ continue
141
+ top_project = a["projects"].most_common(1)[0][0] if a["projects"] else None
142
+ warm.append({
143
+ "handle": handle,
144
+ "last_30d": a["n"],
145
+ "replied": a["replied"],
146
+ "child_replies": a["child_replies"],
147
+ "upvotes": a["upvotes"],
148
+ "project_name": top_project,
149
+ })
150
+ warm.sort(key=lambda x: (x["child_replies"], x["last_30d"]), reverse=True)
151
+ return warm
152
+
153
+
154
+ def pick_default_project(cfg: dict, fallback_candidates: list[dict]) -> str | None:
155
+ for c in fallback_candidates:
156
+ if c.get("project_name"):
157
+ return c["project_name"]
158
+ projects = cfg.get("projects") or []
159
+ return projects[0].get("name") if projects else None
160
+
161
+
162
+ def build_warm_entry(w: dict) -> str:
163
+ entry = (
164
+ f"@{w['handle']} — repeat constructive practitioner "
165
+ f"({w['replied']}/{w['last_30d']} replied in 30d, {w['child_replies']} child "
166
+ f"replies, 0 risk skips); warm to more engaged/product-relevant replies."
167
+ )
168
+ return entry[:ENTRY_CHAR_LIMIT]
169
+
170
+
171
+ def build_style_note(top: dict) -> str:
172
+ note = (
173
+ f"engagement_style='{top['style']}' shows the best child-reply continuation rate "
174
+ f"({top['child_reply_rate']:.2f}/reply, n={top['n']}); prefer it for technical-"
175
+ "correction replies (concrete metric, name the exact failure mode)."
176
+ )
177
+ if len(note) > ENTRY_CHAR_LIMIT:
178
+ note = note[: ENTRY_CHAR_LIMIT - 1].rsplit(" ", 1)[0] + "…"
179
+ return note
180
+
181
+
182
+ def main():
183
+ ap = argparse.ArgumentParser(description=__doc__)
184
+ ap.add_argument("--platform", default="x")
185
+ ap.add_argument("--days", type=int, default=DEFAULT_DAYS)
186
+ ap.add_argument("--dry-run", action="store_true")
187
+ args = ap.parse_args()
188
+ load_env()
189
+
190
+ rows = fetch_window_rows(args.platform, args.days)
191
+ print(f"[engagement_signal_feedback] fetched {len(rows)} rows over last {args.days}d ({args.platform})", file=sys.stderr)
192
+
193
+ style_ranked = compute_style_stats(rows)
194
+ warm_authors = compute_warm_authors(rows)[:MAX_WARM_PROMOTIONS_PER_RUN]
195
+
196
+ cfg_path_val = None
197
+ try:
198
+ from learned_preferences import config_path
199
+ cfg_path_val = config_path()
200
+ cfg = json.loads(Path(cfg_path_val).read_text())
201
+ except Exception as e:
202
+ print(f"[engagement_signal_feedback] cannot read config: {e}", file=sys.stderr)
203
+ return 1
204
+
205
+ block = get_global_block(cfg)
206
+ existing_prefer = block.get("audience_prefer") or []
207
+ existing_notes = block.get("draft_style_notes") or []
208
+
209
+ audience_prefer_add = []
210
+ for w in warm_authors:
211
+ handle_lower = w["handle"].lower()
212
+ already = any(f"@{handle_lower}" in e.lower() for e in existing_prefer)
213
+ if already:
214
+ continue
215
+ audience_prefer_add.append(build_warm_entry(w))
216
+
217
+ draft_style_notes_add = []
218
+ if style_ranked:
219
+ top = style_ranked[0]
220
+ already_noted = any(f"engagement_style='{top['style']}'" in n for n in existing_notes)
221
+ if not already_noted:
222
+ draft_style_notes_add.append(build_style_note(top))
223
+
224
+ print("[engagement_signal_feedback] style ranking:", file=sys.stderr)
225
+ for s in style_ranked:
226
+ print(f" {s['style']}: n={s['n']} child_reply_rate={s['child_reply_rate']:.2f} upvote_rate={s['upvote_rate']:.2f}", file=sys.stderr)
227
+ print(f"[engagement_signal_feedback] warm candidates this run: {[w['handle'] for w in warm_authors]}", file=sys.stderr)
228
+ print(f"[engagement_signal_feedback] audience_prefer additions: {audience_prefer_add}", file=sys.stderr)
229
+ print(f"[engagement_signal_feedback] draft_style_notes additions: {draft_style_notes_add}", file=sys.stderr)
230
+
231
+ if not audience_prefer_add and not draft_style_notes_add:
232
+ print("[engagement_signal_feedback] nothing new to promote", file=sys.stderr)
233
+ return 0
234
+
235
+ if args.dry_run:
236
+ print("[engagement_signal_feedback] --dry-run: not writing", file=sys.stderr)
237
+ return 0
238
+
239
+ project_name = pick_default_project(cfg, warm_authors)
240
+ if not project_name:
241
+ print("[engagement_signal_feedback] no project available to validate write; aborting", file=sys.stderr)
242
+ return 1
243
+
244
+ plan = {"changes": {}, "rationale": "engagement_signal_feedback.py: downstream reply engagement (child-reply/upvote rates)"}
245
+ if audience_prefer_add:
246
+ plan["changes"]["audience_prefer"] = {"add": audience_prefer_add}
247
+ if draft_style_notes_add:
248
+ plan["changes"]["draft_style_notes"] = {"add": draft_style_notes_add}
249
+
250
+ result = apply_mutations(project_name, plan, cfg_path=cfg_path_val)
251
+ print(f"[engagement_signal_feedback] apply_mutations result: {json.dumps(result)}", file=sys.stderr)
252
+ return 0 if result.get("ok") else 1
253
+
254
+
255
+ if __name__ == "__main__":
256
+ sys.exit(main())
@@ -22,10 +22,12 @@ The feedback loop:
22
22
  `learned_preferences_global` block, whitelist-enforced, with flock +
23
23
  backup + atomic replace.
24
24
  4. Enforcement is SOFT (prompt-level, never a deterministic filter): the
25
- twitter prep prompt embeds every project entry verbatim via
26
- ALL_PROJECTS_JSON (each project dict is stamped with the same global
27
- block at prompt-build time), so the block (with its self-describing
28
- _instruction) reaches the judging/drafting model automatically.
25
+ twitter prep prompt embeds the block exactly ONCE via
26
+ GLOBAL_LEARNED_PREFS_JSON (the 'Global learned preferences' line under
27
+ PROJECT ROUTING; per-project stamping retired 2026-07-14), so the block
28
+ (with its self-describing _instruction) reaches the judging/drafting
29
+ model automatically. `history` is stripped at prompt-build time: it is
30
+ a config.json-only audit changelog with no reader in any prompt.
29
31
  prompt_block() renders the same content for callers that want an
30
32
  explicit section instead of the raw embed.
31
33
 
@@ -81,9 +83,9 @@ MAX_HISTORY = 50
81
83
  MAX_EDIT_EXAMPLES = 5
82
84
  MAX_EXAMPLE_CHARS = 600
83
85
 
84
- # Travels inside the JSON the prep prompt embeds (ALL_PROJECTS_JSON is the
85
- # full project entry), so the drafting model reads its own operating manual
86
- # for the block. Split semantics (2026-07-03, after the persona lane kept
86
+ # Travels inside the global block the prep prompt embeds (once, via
87
+ # GLOBAL_LEARNED_PREFS_JSON), so the drafting model reads its own operating
88
+ # manual for the block. Split semantics (2026-07-03, after the persona lane kept
87
89
  # recycling a rejected draft structure): candidate JUDGING signals stay soft
88
90
  # (judgment, not a hard ban), but draft_style_notes is MANDATORY when
89
91
  # writing; if it doesn't beat the engagement style's structural template,
@@ -108,13 +110,34 @@ DEFAULT_INSTRUCTION = (
108
110
 
109
111
 
110
112
  def config_path() -> str:
111
- explicit = os.environ.get("S4L_CONFIG_PATH")
112
- if explicit:
113
- return explicit
114
- repo = os.environ.get("S4L_REPO_DIR")
115
- if repo:
116
- return os.path.join(repo, "config.json")
117
- return os.path.expanduser("~/social-autoposter/config.json")
113
+ """Delegates to scripts/config.py, THE config resolver (state-dir first,
114
+ symlinks resolved to their real target). This module's os.replace()
115
+ writers previously flattened the operator Mac's config symlink into a
116
+ plain file whenever S4L_CONFIG_PATH was missing from the environment
117
+ (2026-07-11 and 2026-07-13 incidents); the shared resolver's realpath
118
+ makes that impossible regardless of env."""
119
+ try:
120
+ from config import config_path as _shared
121
+
122
+ return _shared()
123
+ except Exception:
124
+ # Direct-invocation fallback (scripts/ not importable): same order.
125
+ explicit = os.environ.get("S4L_CONFIG_PATH")
126
+ if explicit:
127
+ p = os.path.expanduser(explicit)
128
+ else:
129
+ state_dir = os.environ.get("S4L_STATE_DIR") or "~/.social-autoposter-mcp"
130
+ sp = os.path.join(os.path.expanduser(state_dir), "config.json")
131
+ if os.path.exists(sp):
132
+ p = sp
133
+ else:
134
+ repo = os.environ.get("S4L_REPO_DIR")
135
+ p = (
136
+ os.path.join(repo, "config.json")
137
+ if repo
138
+ else os.path.expanduser("~/social-autoposter/config.json")
139
+ )
140
+ return os.path.realpath(p) if os.path.exists(p) else p
118
141
 
119
142
 
120
143
  def _now_iso() -> str:
@@ -701,6 +701,7 @@ def build_summary() -> dict[str, Any]:
701
701
  "twitter_cycle": twitter_cycle_status(),
702
702
  "draft_publish": draft_publish_wrapper_status(),
703
703
  "chrome_relaunches": chrome_relaunch_status(),
704
+ "chrome_crashpad": chrome_crashpad_status(),
704
705
  "cdp_health": cdp_health_status(),
705
706
  "process_count": len(rows),
706
707
  "mem": {
@@ -955,6 +956,50 @@ def chrome_relaunch_status() -> dict[str, Any] | None:
955
956
  return None
956
957
 
957
958
 
959
+ def chrome_crashpad_status() -> dict[str, Any] | None:
960
+ """Chrome crash-dump rate from the GLOBAL Crashpad database.
961
+
962
+ macOS Chrome writes all crash dumps (any --user-data-dir) to
963
+ ~/Library/Application Support/Google/Chrome/Crashpad/completed, NOT the
964
+ profile-local Crashpad dir. Baseline on a healthy box is ~1 dump/week;
965
+ the 2026-07-12 Chrome 150 regression produced 11-31/day of renderer
966
+ CHECK-aborts from the harness profile, and each crash preceded a CDP
967
+ websocket wedge by minutes (memory
968
+ insights_chrome150_renderer_crash_wedge_2026_07_14). Carrying the counts
969
+ plus the running Chrome version on the heartbeat makes the NEXT silent
970
+ Chrome-update regression visible in hours instead of days of stalls."""
971
+ try:
972
+ d = (
973
+ Path.home()
974
+ / "Library/Application Support/Google/Chrome/Crashpad/completed"
975
+ )
976
+ if not d.is_dir():
977
+ return None
978
+ now = time.time()
979
+ count_24h = 0
980
+ total = 0
981
+ newest = 0.0
982
+ for f in d.iterdir():
983
+ if f.suffix != ".dmp":
984
+ continue
985
+ total += 1
986
+ try:
987
+ mt = f.stat().st_mtime
988
+ except OSError:
989
+ continue
990
+ if now - mt <= 86400:
991
+ count_24h += 1
992
+ newest = max(newest, mt)
993
+ out: dict[str, Any] = {"count_24h": count_24h, "total": total}
994
+ if newest:
995
+ out["last_dump_at"] = dt.datetime.fromtimestamp(
996
+ newest, dt.timezone.utc
997
+ ).strftime("%Y-%m-%dT%H:%M:%SZ")
998
+ return out
999
+ except Exception:
1000
+ return None
1001
+
1002
+
958
1003
  def _tail_lines(path: Path, n: int, approx_line_bytes: int = 4096) -> list[str]:
959
1004
  """Return the last `n` lines of a possibly-large file without reading it all.
960
1005
  Reads a bounded tail window (n * approx_line_bytes) from the end. Best-effort."""
@@ -123,6 +123,58 @@ def _thread_url(c: dict) -> str:
123
123
  return ""
124
124
 
125
125
 
126
+ def _reddit_plan_to_candidates(plan: dict) -> list:
127
+ """Adapt a post_reddit.py draft-phase plan into review-queue card entries.
128
+
129
+ The reddit plan shape is {project_name, batch_id, decisions: [...]}, where
130
+ each decision is the full post-ready dict (_draft_iteration's merged
131
+ candidate: thread_url, text, engagement_style, ripen data, campaign
132
+ fields). The card carries the twitter-shaped display fields the menu bar
133
+ knows how to render, PLUS the verbatim decision + plan metadata under
134
+ reddit_* keys so an approval can reconstruct a one-decision plan and hand
135
+ it to `post_reddit.py --phase post` unchanged (locks, URL wrapping,
136
+ campaign suffixes, and log_post all stay in the one battle-tested poster).
137
+ """
138
+ import hashlib
139
+
140
+ out = []
141
+ for d in plan.get("decisions") or []:
142
+ if not isinstance(d, dict) or not d.get("text") or not d.get("thread_url"):
143
+ continue
144
+ # candidate_id gets an "rd-" prefix ON PURPOSE: bare integer ids here
145
+ # would collide with twitter_candidates ids in every id-keyed flow
146
+ # (review-events row flips, bulk-discard PATCH /twitter-candidates/
147
+ # by-id) and silently retire the WRONG twitter row. The prefixed id
148
+ # still satisfies index.ts's merge-by-candidate_id stamping (ids are
149
+ # compared as strings), while the twitter-table flips 404 harmlessly.
150
+ _rid = d.get("id") or d.get("candidate_id")
151
+ if not _rid:
152
+ _rid = hashlib.sha1(d["thread_url"].encode()).hexdigest()[:10]
153
+ out.append({
154
+ "platform": "reddit",
155
+ "candidate_id": f"rd-{_rid}",
156
+ "candidate_url": d.get("thread_url"),
157
+ "thread_url": d.get("thread_url"),
158
+ "thread_author": d.get("thread_author"),
159
+ "thread_text": d.get("thread_title") or "",
160
+ "matched_project": plan.get("project_name"),
161
+ "reply_text": d.get("text"),
162
+ "engagement_style": d.get("engagement_style"),
163
+ "assigned_style": d.get("engagement_style"),
164
+ "search_topic": d.get("search_topic"),
165
+ "reddit_batch_id": plan.get("batch_id"),
166
+ "reddit_decision": d,
167
+ "reddit_plan_meta": {
168
+ k: plan.get(k)
169
+ for k in ("project_name", "batch_id", "style_assignment",
170
+ "generation_trace_path", "session_id",
171
+ "draft_session_id")
172
+ if plan.get(k) is not None
173
+ },
174
+ })
175
+ return out
176
+
177
+
126
178
  # Discovery-time author/engagement fields stamped onto each plan candidate so the
127
179
  # approval card can show them. All already captured on the twitter_candidates row
128
180
  # by the discovery pipeline (and refreshed at T1); no scrape happens here.
@@ -174,6 +226,10 @@ def _sync_with_backend(cands: list) -> tuple[int, int]:
174
226
  if not c.get("posted")
175
227
  and not c.get("terminal")
176
228
  and not c.get("approved")
229
+ # The bulk lookup is /api/v1/twitter-candidates; reddit cards would
230
+ # never match a row there, so skip them (their freshness gating stays
231
+ # with the reddit pipeline's own salvage/expiry lanes).
232
+ and c.get("platform") != "reddit"
177
233
  and _thread_url(c)
178
234
  ]
179
235
  if not pending:
@@ -216,16 +272,21 @@ def main() -> int:
216
272
  "--plan-from-marker",
217
273
  help="text containing a DRAFT_ONLY_PLAN=<path> marker (e.g. cycle stdout)",
218
274
  )
275
+ ap.add_argument(
276
+ "--reddit-plan",
277
+ help="path to a post_reddit.py draft-phase plan (decisions[] shape); "
278
+ "adapted into reddit cards instead of the twitter candidates[] shape",
279
+ )
219
280
  ap.add_argument("--project", default=None, help="project name for the review-request marker")
220
281
  ns = ap.parse_args()
221
282
 
222
- src = ns.plan
283
+ src = ns.plan or ns.reddit_plan
223
284
  if not src and ns.plan_from_marker:
224
285
  m = re.search(r"DRAFT_ONLY_PLAN=(\S+\.json)", ns.plan_from_marker)
225
286
  if m:
226
287
  src = m.group(1)
227
288
  if not src:
228
- print("[merge_review_queue] no source plan (need --plan or a DRAFT_ONLY_PLAN marker)", file=sys.stderr)
289
+ print("[merge_review_queue] no source plan (need --plan, --reddit-plan or a DRAFT_ONLY_PLAN marker)", file=sys.stderr)
229
290
  return 2
230
291
  if not os.path.exists(src):
231
292
  print(f"[merge_review_queue] source plan not found: {src}", file=sys.stderr)
@@ -238,7 +299,10 @@ def main() -> int:
238
299
  print(f"[merge_review_queue] could not read source plan: {e}", file=sys.stderr)
239
300
  return 2
240
301
 
241
- new_cands = batch.get("candidates") or []
302
+ if ns.reddit_plan:
303
+ new_cands = _reddit_plan_to_candidates(batch)
304
+ else:
305
+ new_cands = batch.get("candidates") or []
242
306
  if not new_cands:
243
307
  print("[merge_review_queue] source plan has 0 candidates; nothing to merge", file=sys.stderr)
244
308
  return 0