@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,847 @@
1
+ #!/bin/bash
2
+ # Social Autoposter - Original Reddit thread poster (generalized)
3
+ #
4
+ # Picks one (project, subreddit) target via pick_thread_target.py,
5
+ # which enforces per-sub floor and banned-subreddit filtering, then spawns a
6
+ # Claude session with reddit-agent to research, draft, and post ONE original
7
+ # thread.
8
+ #
9
+ # Called by launchd every 6 hours. See com.m13v.social-reddit-threads.plist.
10
+
11
+ set -euo pipefail
12
+
13
+ [ -f "$HOME/social-autoposter/.env" ] && source "$HOME/social-autoposter/.env"
14
+
15
+ # Cycle ID for cross-cycle cost accounting (see run-reddit-search.sh / engage.sh
16
+ # for the same pattern). Stamps claude_sessions.cycle_id so get_run_cost.py
17
+ # --cycle-id reports per-cycle spend instead of bleeding across concurrent runs.
18
+ BATCH_ID="${BATCH_ID:-rdthr-$(date +%Y%m%d-%H%M%S)}"
19
+ export BATCH_ID
20
+ export SA_CYCLE_ID="$BATCH_ID"
21
+
22
+ REPO_DIR="$HOME/social-autoposter"
23
+ CONFIG_FILE="$REPO_DIR/config.json"
24
+ SKILL_FILE="$REPO_DIR/SKILL.md"
25
+ LOG_DIR="$REPO_DIR/skill/logs"
26
+ mkdir -p "$LOG_DIR"
27
+ LOG_FILE="$LOG_DIR/run-reddit-threads-$(date +%Y-%m-%d_%H%M%S).log"
28
+
29
+ echo "=== Reddit Threads Run: $(date) ===" | tee "$LOG_FILE"
30
+ RUN_START_EPOCH=$(date +%s)
31
+
32
+ # Match run-reddit-search.sh:38 / engage.sh:46. Without this, the 4 `log "..."`
33
+ # calls added in 5e41d96 (2026-05-10) fall through to macOS /usr/bin/log,
34
+ # which barfs `Unknown subcommand` and ERR-traps the run at exit=64 before
35
+ # the browser lease is ever acquired (silent kill since 2026-05-10).
36
+ log() { echo "[$(date +%H:%M:%S)] $*" | tee -a "$LOG_FILE"; }
37
+
38
+ # Diagnostic: log the failing line and command before set -e kills the script.
39
+ # Without this, silent deaths (e.g., Claude exits non-zero inside the $() below)
40
+ # leave only the context block in the log with no clue what killed the run.
41
+ trap 'rc=$?; echo "SCRIPT DIED line=$LINENO cmd=\"$BASH_COMMAND\" exit=$rc" | tee -a "$LOG_FILE" >&2' ERR
42
+
43
+ # Pipeline lock at top. The reddit-browser lock is acquired later, just
44
+ # before the Claude/MCP step that drives the browser, so peers can use the
45
+ # profile during our pre-Claude research + prompt build.
46
+ source "$REPO_DIR/skill/lock.sh"
47
+ # reddit-harness backend (2026-05-29). Sets MCP_CONFIG_FILE, BROWSER_INSTRUCTIONS,
48
+ # exports REDDIT_CDP_URL=:9557, provides ensure_reddit_browser_for_backend.
49
+ source "$REPO_DIR/skill/lib/reddit-backend.sh"
50
+ acquire_lock "reddit-threads" 600
51
+
52
+ # Load engagement styles.
53
+ # 2026-05-25: switched from generate_styles_block (which throws away the
54
+ # picker assignment) to the explicit saps_pick_style + saps_render_style_block
55
+ # pair. PICKED_STYLE/PICK_MODE flow into the DB-insert heredoc below as env
56
+ # vars so validate_or_register can coerce USE-mode drift back to the assigned
57
+ # style, matching post_reddit.py / twitter_post_plan.py / post_github.py
58
+ # semantics. Without this, the prompt's "pick from the list" instruction is
59
+ # unenforced and any drifted/invented name silently lands in posts.engagement_style.
60
+ source "$REPO_DIR/skill/styles.sh"
61
+ STYLE_ASSIGN_FILE=$(mktemp -t saps_reddit_threads_style_XXXXXX.json)
62
+ saps_pick_style reddit posting "$STYLE_ASSIGN_FILE" >/dev/null 2>>"$LOG_FILE" || true
63
+ PICKED_STYLE=$(/usr/bin/python3 -c "import json; print(json.load(open('$STYLE_ASSIGN_FILE')).get('style') or '')" 2>/dev/null || echo "")
64
+ PICK_MODE=$(/usr/bin/python3 -c "import json; print(json.load(open('$STYLE_ASSIGN_FILE')).get('mode','')) " 2>/dev/null || echo "")
65
+ STYLES_BLOCK=$(saps_render_style_block "$STYLE_ASSIGN_FILE" reddit posting)
66
+ echo "engagement_style: picked='${PICKED_STYLE}' mode='${PICK_MODE}'" | tee -a "$LOG_FILE"
67
+
68
+ # RETRY-FROM-PENDING (added 2026-05-01 after r/AutoHotkey MCP-crash incident):
69
+ # Before paying for fresh research+drafting, check if a previously-aborted draft
70
+ # is sitting in pending_threads waiting for a retry. If so, pick it up and skip
71
+ # the research/drafting phase entirely. This reuses sunk Claude cost on prior runs.
72
+ RETRY_PAYLOAD=$(/usr/bin/python3 <<'PYEOF' 2>/dev/null
73
+ import sys, os, json
74
+ sys.path.insert(0, os.path.expanduser("~/social-autoposter/scripts"))
75
+ import pending_threads as pt
76
+ rows = pt.list_pending(project=None)
77
+ # Cap at 3 attempts before abandoning, so a perpetually-broken draft doesn't
78
+ # blackhole every run.
79
+ rows = [r for r in rows if (r.get("attempts") or 0) < 3]
80
+ if not rows:
81
+ print("")
82
+ else:
83
+ # Oldest first.
84
+ r = rows[0]
85
+ print(json.dumps(r))
86
+ PYEOF
87
+ )
88
+
89
+ if [ -n "$RETRY_PAYLOAD" ]; then
90
+ RETRY_MODE=1
91
+ PENDING_ID=$(echo "$RETRY_PAYLOAD" | /usr/bin/python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
92
+ PROJECT=$(echo "$RETRY_PAYLOAD" | /usr/bin/python3 -c "import sys,json; print(json.load(sys.stdin)['project_name'])")
93
+ SUBREDDIT=$(echo "$RETRY_PAYLOAD" | /usr/bin/python3 -c "import sys,json; print(json.load(sys.stdin)['subreddit'])")
94
+ # Pull title, body, flair from the saved draft via the helper
95
+ PENDING_FULL=$(/usr/bin/python3 "$REPO_DIR/scripts/pending_threads.py" get --id "$PENDING_ID")
96
+ PENDING_TITLE=$(echo "$PENDING_FULL" | /usr/bin/python3 -c "import sys,json; print(json.load(sys.stdin).get('title',''))")
97
+ PENDING_BODY=$(echo "$PENDING_FULL" | /usr/bin/python3 -c "import sys,json; print(json.load(sys.stdin).get('body',''))")
98
+ PENDING_FLAIR=$(echo "$PENDING_FULL" | /usr/bin/python3 -c "import sys,json; print(json.load(sys.stdin).get('flair_target') or '')")
99
+ PENDING_STYLE=$(echo "$PENDING_FULL" | /usr/bin/python3 -c "import sys,json; print(json.load(sys.stdin).get('engagement_style') or '')")
100
+ PENDING_TOPIC=$(echo "$PENDING_FULL" | /usr/bin/python3 -c "import sys,json; print(json.load(sys.stdin).get('topic_angle') or '')")
101
+ PENDING_SOURCE=$(echo "$PENDING_FULL" | /usr/bin/python3 -c "import sys,json; print(json.load(sys.stdin).get('source_summary') or '')")
102
+ IS_OWN="False" # default; not critical for retry
103
+ echo "RETRY_MODE: pending_id=$PENDING_ID project=$PROJECT subreddit=$SUBREDDIT" | tee -a "$LOG_FILE"
104
+ else
105
+ RETRY_MODE=0
106
+ # Pick target
107
+ TARGET_JSON=$(/usr/bin/python3 "$REPO_DIR/scripts/pick_thread_target.py" --json 2>&1) || {
108
+ echo "NO_ELIGIBLE_TARGET: every eligible subreddit is inside its floor window. Stopping." | tee -a "$LOG_FILE"
109
+ exit 0
110
+ }
111
+
112
+ PROJECT=$(echo "$TARGET_JSON" | /usr/bin/python3 -c "import sys,json; print(json.load(sys.stdin)['project']['name'])")
113
+ SUBREDDIT=$(echo "$TARGET_JSON" | /usr/bin/python3 -c "import sys,json; print(json.load(sys.stdin)['subreddit'])")
114
+ IS_OWN=$(echo "$TARGET_JSON" | /usr/bin/python3 -c "import sys,json; print(json.load(sys.stdin)['is_own_community'])")
115
+
116
+ echo "Target: project=$PROJECT subreddit=$SUBREDDIT own_community=$IS_OWN" | tee -a "$LOG_FILE"
117
+ fi
118
+ SUB_SLUG=$(echo "$SUBREDDIT" | sed 's|^r/||I')
119
+
120
+ # Posting account (hardcoded for now; the only configured reddit account)
121
+ POST_ACCOUNT=$(/usr/bin/python3 -c "
122
+ import json
123
+ c = json.load(open('$CONFIG_FILE'))
124
+ print(c.get('accounts',{}).get('reddit',{}).get('username','Deep_Ad1959'))
125
+ ")
126
+
127
+ # Build full per-project context block (JSON-driven so prompt stays compact)
128
+ export PROJECT_ENV="$PROJECT"
129
+ export CONFIG_PATH="$CONFIG_FILE"
130
+ CONTEXT_BLOCK=$(/usr/bin/python3 <<'PYEOF'
131
+ import json, datetime, os
132
+ CONFIG = os.environ['CONFIG_PATH']
133
+ name = os.environ['PROJECT_ENV']
134
+ c = json.load(open(CONFIG))
135
+ proj = next((p for p in c['projects'] if p['name'] == name), None)
136
+ if not proj:
137
+ print("(project not found)")
138
+ raise SystemExit(0)
139
+
140
+ t = proj.get('threads') or {}
141
+ lp = proj.get('landing_pages') or {}
142
+
143
+ out = []
144
+ out.append(f"Project: {proj['name']}")
145
+ out.append(f"Description: {proj.get('description','').strip()}")
146
+ if proj.get('website'): out.append(f"Website: {proj['website']}")
147
+ if lp.get('base_url'): out.append(f"Base URL: {lp['base_url']}")
148
+ if proj.get('content_angle'):
149
+ out.append(f"\nContent angle: {proj['content_angle']}")
150
+
151
+ voice = proj.get('voice')
152
+ if voice:
153
+ out.append(f"\nVoice tone: {voice.get('tone','')}")
154
+ if voice.get('never'):
155
+ out.append("Voice never: " + "; ".join(voice['never']))
156
+
157
+ # Dynamic day counter
158
+ dc = t.get('dynamic_context') or {}
159
+ day = dc.get('day_counter')
160
+ if day:
161
+ base = day['base_count']
162
+ ref = datetime.date.fromisoformat(day['ref_date'])
163
+ days = (datetime.date.today() - ref).days
164
+ count = base + days
165
+ label = day.get('label','day count')
166
+ out.append(f"\nLive {label}: {count}+")
167
+ for f in dc.get('static_facts') or []:
168
+ out.append(f"- {f}")
169
+
170
+ # Topic angles
171
+ angles = t.get('topic_angles') or []
172
+ if angles:
173
+ out.append("\nTopic angles to choose from:")
174
+ for a in angles:
175
+ out.append(f"- {a}")
176
+
177
+ # Source paths (SEO pipeline pattern)
178
+ out.append("\n## Product source (READ for context before drafting)")
179
+ repo = lp.get('repo','')
180
+ if repo:
181
+ rp = os.path.expanduser(repo)
182
+ status = "" if os.path.isdir(rp) else " [MISSING ON DISK]"
183
+ out.append(f"- Website repo: {rp}{status}")
184
+ for s in lp.get('product_source') or []:
185
+ p = os.path.expanduser(s.get('path',''))
186
+ status = "" if os.path.isdir(p) else " [MISSING]"
187
+ desc = s.get('description','').strip()
188
+ out.append(f"- {p}{status}\n {desc}")
189
+
190
+ # Threads content_sources
191
+ cs = t.get('content_sources') or {}
192
+ if cs.get('guide_dir'):
193
+ gd = os.path.expanduser(cs['guide_dir'])
194
+ out.append(f"\nGuide dir (read page.tsx files here for specific detail): {gd}")
195
+ if cs.get('link_base'):
196
+ out.append(f"Link base for any URL you include: {cs['link_base']}")
197
+ if cs.get('read_instructions'):
198
+ out.append(cs['read_instructions'])
199
+
200
+ print("\n".join(out))
201
+ PYEOF
202
+ )
203
+
204
+ echo "--- Context block ---" | tee -a "$LOG_FILE"
205
+ echo "$CONTEXT_BLOCK" | tee -a "$LOG_FILE"
206
+ echo "---------------------" | tee -a "$LOG_FILE"
207
+
208
+ # HTTP-only lane (2026-06-01): the four prompt-context reads go through the
209
+ # s4l.ai API via scripts/reddit_threads_helper.py. No DATABASE_URL, no psql,
210
+ # no fallback. Each subcommand prints exactly what the psql -t -A call printed
211
+ # (one row per line, `|`-delimited) so the prompt context is byte-identical.
212
+ RT_HELPER="$REPO_DIR/scripts/reddit_threads_helper.py"
213
+
214
+ # Recent posts in THIS sub (avoid repeats - include endings for closer variation)
215
+ RECENT_POSTS_SUB=$(python3 "$RT_HELPER" recent-posts-sub --sub "$SUB_SLUG" --limit 10 2>/dev/null || echo "(api error)")
216
+
217
+ # Recent posts project-wide (cross-sub dedup - include endings)
218
+ RECENT_POSTS_PROJECT=$(python3 "$RT_HELPER" recent-posts-project --project "$PROJECT" --days 14 --limit 15 2>/dev/null || echo "(api error)")
219
+
220
+ # Recent engagement styles for this project on THIS platform (avoid repeating).
221
+ # Scoped to platform='reddit' because cross-platform history conflated tiers —
222
+ # a Moltbook post yesterday was blocking a Reddit style today for no reason.
223
+ RECENT_STYLES=$(python3 "$RT_HELPER" recent-styles --project "$PROJECT" --limit 5 2>/dev/null || echo "(api error)")
224
+
225
+ # Top performers (tone calibration)
226
+ TOP_POSTS=$(python3 "$RT_HELPER" top-posts --project "$PROJECT" --min-score 5 --limit 10 2>/dev/null || echo "(api error)")
227
+
228
+ if [ "$IS_OWN" = "True" ]; then
229
+ CADENCE_NOTE="This is our OWNED subreddit. Daily cadence (1-day floor). Be yourself, no product pitches."
230
+ else
231
+ CADENCE_NOTE="This is an EXTERNAL subreddit (3-day floor). The thread must pass the sub's self-promo bar. No product links unless genuinely relevant (max 1)."
232
+ fi
233
+
234
+ # JSON schema: forces the model to return structured output with all required fields.
235
+ # This is how we enforce step compliance programmatically.
236
+ RESULT_SCHEMA='{"type":"object","properties":{"research_files_read":{"type":"array","items":{"type":"string"},"description":"Absolute paths of source files actually read during research step"},"subreddit_browsed":{"type":"boolean","description":"Whether you navigated to the subreddit hot page and read threads"},"hot_threads_seen":{"type":"array","items":{"type":"string"},"description":"Titles of 3-5 hot threads you read on the subreddit"},"topic_angle":{"type":"string","description":"The topic angle chosen from the list"},"engagement_style":{"type":"string","description":"The engagement style chosen"},"title":{"type":"string","description":"The exact post title submitted"},"body":{"type":"string","description":"The exact post body submitted"},"permalink":{"type":["string","null"],"description":"The Reddit permalink after successful submission, or null if aborted"},"rules_checked":{"type":"boolean","description":"Whether you checked subreddit rules"},"flair_applied":{"type":["string","null"],"description":"Flair text applied, or null if none"},"abort_reason":{"type":["string","null"],"description":"Reason for aborting, or null if posted successfully"},"permanent_block":{"type":"boolean","description":"Set TRUE only if this subreddit will reject EVERY future post from this account: account-banned, link-only sub, mod rule banning our entire category (e.g. all software/website posts), approved-submitters-only, or any standing rule that makes future thread posts impossible. Set FALSE for one-off issues (this specific topic violates a rule, repetition, transient errors). When TRUE, the sub is added to thread_blocked permanently and never picked again. Default FALSE."},"source_summary":{"type":"string","description":"Rich source summary: (a) topic angle and why, (b) source files read, (c) specific details used"}},"required":["research_files_read","subreddit_browsed","hot_threads_seen","topic_angle","engagement_style","title","body","permalink","rules_checked","flair_applied","abort_reason","permanent_block","source_summary"]}'
237
+
238
+ # Pre-generate session id so the prompt's inline INSERT can stamp it.
239
+ export CLAUDE_SESSION_ID=$(uuidgen | tr 'A-Z' 'a-z')
240
+
241
+ # Acquire the browser lock now, immediately before the Claude/MCP step.
242
+ #
243
+ # Lock strategy (changed 2026-05-10): switched from 3600s bash lock held for
244
+ # the entire Claude session to a 90s Python lease lock. The reddit-agent MCP
245
+ # proxy (scripts/mcp_lock_proxy.py) heartbeats expires_at on every browser
246
+ # tool call, so the lease stays held during real activity but auto-decays
247
+ # within 90s of idleness (Claude reasoning gaps, research file reads, etc.).
248
+ # Peer pipelines (run-reddit-search post phase, engage-reddit, dm-replies-reddit,
249
+ # link-edit-reddit) can use the profile during our long Claude thinking phases.
250
+ #
251
+ # Unified lock (2026-05-10): only the Python lease. The bash pre-flight was
252
+ # removed because lock.sh did not honor expires_at and would block on
253
+ # TTL-stale-but-PID-alive holders. Python acquire performs the orphan-Chrome
254
+ # sweep internally (ported from lock.sh:175-198); ensure_browser_healthy then
255
+ # runs under the exclusive Python lease that follows.
256
+ log "Acquiring reddit-browser lease (TTL 90s, MCP-proxy heartbeated)..."
257
+ python3 "$REPO_DIR/scripts/reddit_browser_lock.py" acquire --timeout 600 --ttl 90 2>&1 | tee -a "$LOG_FILE" || \
258
+ log "WARNING: reddit_browser_lock.py acquire failed; proceeding without lease (peer pipelines may collide)."
259
+ if ! ensure_reddit_browser_for_backend 2>&1 | tee -a "$LOG_FILE"; then
260
+ log "WARNING: reddit-harness bootstrap failed; falling back to ensure_browser_healthy reddit"
261
+ ensure_browser_healthy "reddit"
262
+ fi
263
+
264
+ # NOTE 2026-05-07: removed broken pre-flight Chrome health check (commit
265
+ # 971844d, 2026-05-04). Intent was to short-circuit before Claude drafted for
266
+ # $4-5 in a session where reddit-agent MCP tools wouldn't load. But the check
267
+ # probed for an already-running Chrome with a CDP port; Chrome only launches
268
+ # INSIDE the Claude session via launch_persistent_context, so the probe always
269
+ # saw "no port" and aborted every fire. 22 launchd fires, 0 posts from
270
+ # 2026-05-04 12:13 through 2026-05-07. Aligned with run-reddit-search.sh and
271
+ # engage-reddit.sh, which call ensure_browser_healthy and then trust the
272
+ # Claude/MCP step to launch Chrome — they kept posting comments fine the whole
273
+ # time. Re-introducing a real "MCP loaded?" probe is Phase 2 (separate cheap
274
+ # claude -p tool-list call before drafting); for now we accept the same $5
275
+ # tail-risk per fire that every other reddit pipeline already accepts.
276
+
277
+ # Capture Claude output to a temp file so a non-zero exit doesn't swallow stderr
278
+ # before we get a chance to log it. Without this, run_claude.sh failures look
279
+ # like "SCRIPT DIED line=283 exit=1" with zero context.
280
+ CLAUDE_TMP=$(mktemp)
281
+ set +e
282
+ if [ "$RETRY_MODE" = "1" ]; then
283
+ "$REPO_DIR/scripts/run_claude.sh" "run-reddit-threads" --strict-mcp-config --mcp-config "$MCP_CONFIG_FILE" -p --output-format json --json-schema "$RESULT_SCHEMA" "You are RETRYING a previously-aborted thread for the ${PROJECT} project as u/${POST_ACCOUNT}.
284
+
285
+ $BROWSER_INSTRUCTIONS
286
+
287
+ ## CRITICAL: This is a RETRY. The title and body are PRE-WRITTEN and FINAL.
288
+ DO NOT redraft. DO NOT research. DO NOT browse the subreddit. DO NOT pick a topic.
289
+ You are ONLY driving the browser through the submit flow with the exact strings below.
290
+
291
+ ## Saved draft (USE EXACTLY AS-IS)
292
+ Subreddit: ${SUBREDDIT}
293
+ Title: ${PENDING_TITLE}
294
+
295
+ Body:
296
+ ${PENDING_BODY}
297
+
298
+ Topic angle (for log purposes only): ${PENDING_TOPIC}
299
+ Engagement style (for log purposes only): ${PENDING_STYLE}
300
+ Source summary (for log purposes only): ${PENDING_SOURCE}
301
+ Flair target (if known from prior attempt): ${PENDING_FLAIR}
302
+
303
+ ## Workflow
304
+
305
+ 1. Navigate to https://old.reddit.com/${SUBREDDIT}/submit?selftext=true (bh_run: goto_url + wait_for_load).
306
+
307
+ 2. Fill the title and body using the exact saved strings above. Use js() to set the values
308
+ directly (most reliable on old.reddit's md-container wrapper):
309
+ document.querySelector('textarea[name=\"title\"]').value = TITLE;
310
+ document.querySelector('textarea[name=\"title\"]').dispatchEvent(new Event('input',{bubbles:true}));
311
+ document.querySelector('textarea[name=\"text\"]').value = BODY;
312
+ document.querySelector('textarea[name=\"text\"]').dispatchEvent(new Event('input',{bubbles:true}));
313
+
314
+ 3. FLAIR HELPER (if flair required, old.reddit verified 2026-05-01 on r/AutoHotkey).
315
+ OLD.REDDIT SELECTORS ARE STALE in the wild: it is NOT '.flairselector-button',
316
+ NOT '.flairoption', and the confirm button is NOT 'Save'. Do not rely on those.
317
+ Use the visible text/structure described below.
318
+ a. Look for a group labeled around 'choose a flair'. Inside it is a button whose
319
+ visible text is exactly 'select' (lowercase). Click that button.
320
+ b. A modal opens. Header is 'select flair'. Body is a <ul> of <li> rows; each <li>
321
+ is a clickable flair option. Click the <li> matching the saved flair_target above
322
+ (or pick a sensible match if blank, e.g. 'Meta / Discussion', 'Question', 'Help').
323
+ c. Confirm by clicking 'apply' (lowercase, NOT 'Save').
324
+ d. Verify the chosen flair name appears next to the title (replacing '(none)').
325
+ If the 'select' button doesn't open a modal, OR no <li> matches, OR 'apply' is
326
+ missing: ABORT with abort_reason='flair_ui_unexpected' or 'no_suitable_flair'.
327
+ Do NOT loop or retry the click more than twice.
328
+
329
+ 4. Click the submit button (visible text 'submit', lowercase on old.reddit). Wait 3
330
+ seconds. Capture the permalink (document.location.href after submission). Close the tab.
331
+
332
+ 5. Return structured JSON. Use the saved title, body, topic_angle, engagement_style, and
333
+ source_summary as-is for the log fields. Fill permalink with the actual URL if posted,
334
+ or null if aborted. Set rules_checked=true (they were checked in the original attempt).
335
+ Set subreddit_browsed=false and hot_threads_seen=[] (we're not re-doing browse work).
336
+ Set research_files_read=[] (we're reusing prior research). Set permanent_block=false
337
+ unless the submit step itself returned a forbidden/403 error.
338
+
339
+ CRITICAL: NEVER use em dashes.
340
+ CRITICAL: Use ONLY the browser tool described in the BROWSER BACKEND block above (mcp__reddit-harness__bh_run).
341
+ CRITICAL: If mcp__reddit-harness__bh_run is NOT available in this session (you cannot find it as deferred or callable), return JSON immediately with permalink=null and abort_reason='mcp_browser_unavailable'. DO NOT spawn Python, shell, headless or headed Playwright as a fallback. DO NOT use mcp__reddit-agent__*, mcp__playwright-extension__*, or any other browser MCP. The pipeline depends on the reddit-harness backend specifically.
342
+ CRITICAL: Reuse the SAME tab via goto_url for sequential navigation (the harness keeps one real tab); do NOT open a fresh tab per step.
343
+ CRITICAL: If a browser call times out, wait 30s and retry up to 3 times.
344
+ CRITICAL: This is a RETRY of a \$4-24 sunk-cost draft. Do NOT redraft, do NOT research." > "$CLAUDE_TMP" 2>&1
345
+ CLAUDE_RC=$?
346
+ else
347
+ "$REPO_DIR/scripts/run_claude.sh" "run-reddit-threads" --strict-mcp-config --mcp-config "$MCP_CONFIG_FILE" -p --output-format json --json-schema "$RESULT_SCHEMA" "You are posting an ORIGINAL thread to ${SUBREDDIT} for the ${PROJECT} project as u/${POST_ACCOUNT}.
348
+
349
+ $BROWSER_INSTRUCTIONS
350
+
351
+ ## Config & Rules
352
+ Read $SKILL_FILE for content rules and anti-AI-detection checklist.
353
+ You may also open $CONFIG_FILE for the full project block if you need anything not summarized below.
354
+
355
+ ## Target
356
+ Project: ${PROJECT}
357
+ Subreddit: ${SUBREDDIT}
358
+ Own community: ${IS_OWN}
359
+ ${CADENCE_NOTE}
360
+
361
+ ## Project context (live-assembled)
362
+ ${CONTEXT_BLOCK}
363
+
364
+ ${STYLES_BLOCK}
365
+
366
+ ## Recent posts by us in ${SUBREDDIT} (DO NOT repeat topics OR closers)
367
+ Each entry shows: title |ENDING| last 200 chars of post body. Study the endings to vary your closer.
368
+ ${RECENT_POSTS_SUB}
369
+
370
+ ## Recent posts by us for ${PROJECT} across all subs (last 14d, don't recycle angle OR closing style)
371
+ ${RECENT_POSTS_PROJECT}
372
+
373
+ ## Recent engagement styles for ${PROJECT} (avoid repeating the same style back-to-back)
374
+ ${RECENT_STYLES}
375
+
376
+ ## Top performing ${PROJECT} posts (match tone/style)
377
+ ${TOP_POSTS}
378
+
379
+ ## Workflow
380
+
381
+ 1. RESEARCH (required): Read the product source paths listed in the context block. Specifically:
382
+ - README.md at the repo root
383
+ - Any files under src/ or docs/ that relate to your chosen topic angle
384
+ - For Vipassana: read relevant page.tsx under the guide dir
385
+ Pull 1-2 concrete, specific details from the source code or docs to anchor the post. Generic posts get ignored.
386
+
387
+ 2. BROWSE THE SUBREDDIT: Navigate to https://old.reddit.com/${SUBREDDIT}/hot (bh_run: goto_url + wait_for_load).
388
+ - Read 3-5 recent thread titles and their top comments to absorb community tone, vocabulary, and what topics are getting engagement right now.
389
+ - Note any recurring themes or hot-button issues the community cares about today.
390
+ This shapes your post to sound like it belongs in the current conversation, not like a scheduled drop.
391
+
392
+ 3. Pick a topic from the threads.topic_angles list (in the context block above) that:
393
+ - Has NOT been posted recently in this subreddit (see above)
394
+ - Is not a recycled angle from other subs (see project-wide list)
395
+ - Fits this subreddit's community and rules
396
+ - Invites genuine discussion (end with a question or open thread)
397
+ - Pick an engagement_style from the styles list above that:
398
+ (a) fits the topic and subreddit culture
399
+ (b) is NOT one of the last 3 styles used for this project (see recent styles above)
400
+
401
+ 4. Draft the post. RULES:
402
+ - No em dashes anywhere. Commas, periods, or plain '-' only.
403
+ - No markdown formatting (no ##, no **bold**, no bullet lists).
404
+ - 2-4 short paragraphs, casual tone. Narrator voice follows the VOICE RELATIONSHIP block in the styles section.
405
+ - Include at least one imperfection (sentence fragment, aside, lowercase start).
406
+ - Title: lowercase, no clickbait patterns, no emojis.
407
+ - Ground in a specific detail from the product source you read in step 1.
408
+ - Follow the voice guidance from the project context. Read it out loud; if it sounds like a blog post, rewrite.
409
+ - VARY YOUR CLOSERS: check how recent posts ended (shown after |ENDING| above). Use a DIFFERENT ending pattern. Banned closers: 'curious if anyone', 'anyone else', 'thoughts?', 'has anyone'. Sometimes end with a statement, sometimes mid-thought, sometimes a specific (not generic) question.
410
+ - VARY CAPITALIZATION: do NOT lowercase every sentence start. Mix it naturally: some sentences capitalized, some not. Uniform all-lowercase is a known AI tell.
411
+
412
+ 5. SUBREDDIT RULES CHECK (bh_run: goto_url to https://old.reddit.com/${SUBREDDIT}/about/rules + wait_for_load)
413
+ - If strict no-self-promo and our post would read promotional, ABORT. Set abort_reason and permalink=null.
414
+ - Note whether flair is required.
415
+
416
+ PERMANENT_BLOCK DECISION (always set this field):
417
+ - permanent_block = TRUE if the sub has a STANDING rule that rejects every post we could ever make from this account: bans all software/website/AI posts (mod-pinned), link-only sub, approved-submitters-only, account is banned from this sub, no-self-promo with zero exceptions for our category. ALSO set TRUE on submit-time forbidden / 403.
418
+ - permanent_block = FALSE if the issue is specific to THIS post (recent topic was already covered, this title is too promotional, you chose to abort to be safe but the sub itself does accept posts of this type, transient browser/network error, repetition concern).
419
+ - When in doubt, FALSE. False positives are cheap (we just retry the sub later); false negatives waste a Claude run cost (\$1.50-3.50 USD) every time we re-pick the same dead-end sub.
420
+
421
+ 6. POST (bh_run):
422
+ - Navigate to https://old.reddit.com/${SUBREDDIT}/submit?selftext=true (goto_url + wait_for_load)
423
+ - Fill title and body. Set the values directly via js() (most reliable on the md-container wrapper):
424
+ document.querySelector('textarea[name=\"title\"]').value = TITLE;
425
+ document.querySelector('textarea[name=\"title\"]').dispatchEvent(new Event('input',{bubbles:true}));
426
+ document.querySelector('textarea[name=\"text\"]').value = BODY;
427
+ document.querySelector('textarea[name=\"text\"]').dispatchEvent(new Event('input',{bubbles:true}));
428
+ - FLAIR HELPER (if flair required, old.reddit verified 2026-05-01 on r/AutoHotkey).
429
+ OLD.REDDIT SELECTORS ARE STALE in the wild: it is NOT '.flairselector-button',
430
+ NOT '.flairoption', and the confirm button is NOT 'Save'. Do not rely on those.
431
+ Use the visible text/structure described below.
432
+ a. After the post body is typed, look for a group labeled around 'choose a flair'.
433
+ Inside it is a button whose visible text is exactly 'select' (lowercase).
434
+ Compute its center coords from getBoundingClientRect via js(), then click_at_xy(x, y).
435
+ b. A modal opens. Header is 'select flair'. Body is a <ul> of <li> rows; each <li>
436
+ is a clickable flair option (cursor: pointer). There is no .flairoption class.
437
+ Find the <li> whose text matches the right flair (e.g. 'Meta / Discussion',
438
+ 'Question', 'Help', 'Showcase'). Click that <li>.
439
+ c. The confirm button is labeled 'apply' (lowercase). Click 'apply'. Do NOT look
440
+ for 'Save', 'OK', 'Confirm', or 'Submit' inside the modal — they don't exist.
441
+ d. Verify success by re-reading the snapshot: the '(none)' placeholder next to
442
+ the title should be replaced by the chosen flair name (typically rendered green).
443
+ If the 'select' button doesn't open a modal, OR no <li> matches a sensible flair,
444
+ OR the 'apply' button is missing: ABORT with abort_reason='flair_ui_unexpected'
445
+ or 'no_suitable_flair'. Do NOT loop or retry the click more than twice — repeated
446
+ clicks have crashed the chrome MCP child in past runs (2026-05-01 r/AutoHotkey).
447
+ - Click the submit button (visible text 'submit', lowercase on old.reddit).
448
+ Wait 3 seconds. Capture the permalink (document.location.href after submission).
449
+ - Close the tab.
450
+
451
+ 7. DO NOT touch the database. The shell wrapper handles the INSERT after you return.
452
+ IMPORTANT: source_summary, title, body, permalink, engagement_style in your
453
+ JSON output ARE what get logged. Make source_summary rich and grounded in
454
+ the specific files/details you read in step 1.
455
+ ABORT-SAFE: if you abort AFTER drafting (e.g. flair UI broke, MCP child died,
456
+ submit button vanished), still return the title + body you drafted in your
457
+ JSON. The shell wrapper will persist them to pending_threads so the next
458
+ pipeline run can retry the post WITHOUT re-paying for research/drafting.
459
+
460
+ 8. Return your structured JSON output. Every field in the schema is required. Fill permalink with the actual URL if posted, or null if aborted.
461
+
462
+ CRITICAL: NEVER use em dashes.
463
+ CRITICAL: Use ONLY the browser tool described in the BROWSER BACKEND block above (mcp__reddit-harness__bh_run).
464
+ CRITICAL: If mcp__reddit-harness__bh_run is NOT available in this session (you cannot find it as deferred or callable), return JSON immediately with permalink=null and abort_reason='mcp_browser_unavailable'. DO NOT spawn Python, shell, headless or headed Playwright as a fallback. DO NOT use mcp__reddit-agent__*, mcp__playwright-extension__*, or any other browser MCP. The pipeline depends on the reddit-harness backend specifically. The shell wrapper persists your draft to pending_threads on abort, so a clean ABORT-SAFE return preserves the work.
465
+ CRITICAL: Reuse the SAME tab via goto_url for sequential navigation (the harness keeps one real tab); do NOT open a fresh tab per step.
466
+ CRITICAL: If a browser call times out, wait 30s and retry up to 3 times." > "$CLAUDE_TMP" 2>&1
467
+ CLAUDE_RC=$?
468
+ fi # end RETRY_MODE branch
469
+ set -e
470
+ CLAUDE_OUTPUT=$(cat "$CLAUDE_TMP")
471
+ rm -f "$CLAUDE_TMP"
472
+
473
+ # Belt-and-suspenders: free the reddit-browser lease if it's still held.
474
+ # Idempotent — release prints OK / NOT_HELD / HELD_BY_OTHER. Mirrors
475
+ # link-edit-reddit.sh:185 and engage-dm-replies.sh:1439. If Claude crashed
476
+ # mid-post the lease auto-decays in 90s, but explicit release frees peers
477
+ # immediately so they can use the profile during the rest of this run's
478
+ # DB writes / logging.
479
+ timeout 3 python3 "$REPO_DIR/scripts/reddit_browser_lock.py" release 2>/dev/null || true
480
+
481
+ # Parse structured output and log results
482
+ echo "$CLAUDE_OUTPUT" | tee -a "$LOG_FILE"
483
+ if [ "$CLAUDE_RC" -ne 0 ]; then
484
+ echo "RUN_CLAUDE_NONZERO_EXIT rc=$CLAUDE_RC (output above is full stderr+stdout)" | tee -a "$LOG_FILE"
485
+ fi
486
+
487
+ # Extract structured_output from the JSON envelope.
488
+ # claude -p --output-format json wraps results as: {"structured_output": {...}, "result": "...", ...}
489
+ PARSED=$(/usr/bin/python3 -c "
490
+ import json,sys
491
+ try:
492
+ raw = sys.stdin.read()
493
+ # run_claude.sh appends a log line to stderr but 2>&1 captures it here too,
494
+ # giving us two concatenated JSON objects. raw_decode stops after the first.
495
+ d, _ = json.JSONDecoder().raw_decode(raw)
496
+ so = d.get('structured_output') or d
497
+ print(json.dumps(so))
498
+ except Exception as e:
499
+ print(json.dumps({'_parse_error': str(e)}))
500
+ " <<< "$CLAUDE_OUTPUT" 2>/dev/null)
501
+
502
+ PERMALINK=$(/usr/bin/python3 -c "import json,sys; r=json.loads(sys.stdin.read()); print(r.get('permalink') or 'null')" <<< "$PARSED" 2>/dev/null)
503
+ TITLE=$(/usr/bin/python3 -c "import json,sys; r=json.loads(sys.stdin.read()); print(r.get('title',''))" <<< "$PARSED" 2>/dev/null)
504
+ ABORT_REASON=$(/usr/bin/python3 -c "import json,sys; r=json.loads(sys.stdin.read()); print(r.get('abort_reason') or '')" <<< "$PARSED" 2>/dev/null)
505
+ # Explicit permanent-block signal from the model. Trusted when present;
506
+ # regex fallback in mark_thread_blocked still runs if Claude omits it.
507
+ PERMANENT_BLOCK=$(/usr/bin/python3 -c "import json,sys; r=json.loads(sys.stdin.read()); print('1' if r.get('permanent_block') is True else '0')" <<< "$PARSED" 2>/dev/null)
508
+
509
+ # Log step compliance summary
510
+ /usr/bin/python3 -c "
511
+ import json,sys
512
+ r = json.loads(sys.stdin.read())
513
+ if '_parse_error' in r:
514
+ print(f'Step compliance: PARSE ERROR ({r[\"_parse_error\"]})')
515
+ else:
516
+ files = r.get('research_files_read', [])
517
+ browsed = r.get('subreddit_browsed', False)
518
+ hot = r.get('hot_threads_seen', [])
519
+ rules = r.get('rules_checked', False)
520
+ style = r.get('engagement_style', '?')
521
+ print(f'Step compliance: research={len(files)} files, browsed={browsed}, hot_threads={len(hot)}, rules_checked={rules}, style={style}')
522
+ " <<< "$PARSED" 2>/dev/null | tee -a "$LOG_FILE"
523
+
524
+ if [ "$PERMALINK" != "null" ] && [ "$PERMALINK" != "PARSE_ERROR" ]; then
525
+ echo "POSTED: $PERMALINK | $TITLE" | tee -a "$LOG_FILE"
526
+
527
+ # Authoritative DB INSERT.
528
+ # Historical bug: step 7 of the prompt asked Claude to run psql via Bash to
529
+ # log the post. Claude sometimes did, sometimes didn't (e.g. mk0r run id
530
+ # 21486 on 2026-04-29 was orphaned and had to be backfilled by hand). The
531
+ # shell already has every required value parsed out of structured_output, so
532
+ # do the INSERT here and stop trusting the model with a database step.
533
+ PARSED="$PARSED" \
534
+ CLAUDE_SESSION_ID="$CLAUDE_SESSION_ID" \
535
+ PROJECT_ENV="$PROJECT" \
536
+ POST_ACCOUNT="$POST_ACCOUNT" \
537
+ REPO_DIR="$REPO_DIR" \
538
+ PENDING_ID_ENV="${PENDING_ID:-}" \
539
+ PICKED_STYLE_ENV="${PICKED_STYLE:-}" \
540
+ PICK_MODE_ENV="${PICK_MODE:-}" \
541
+ /usr/bin/python3 <<'PYEOF' 2>&1 | tee -a "$LOG_FILE" || true
542
+ import json, os, sys
543
+ sys.path.insert(0, os.path.join(os.environ["REPO_DIR"], "scripts"))
544
+ from http_api import api_post
545
+ import pending_threads as pt
546
+
547
+ parsed = json.loads(os.environ.get("PARSED") or "{}")
548
+ permalink = parsed.get("permalink") or ""
549
+ title = parsed.get("title", "")
550
+ body = parsed.get("body", "")
551
+ summary = parsed.get("source_summary", "")
552
+ raw_style = (parsed.get("engagement_style") or "").strip()
553
+ session = os.environ.get("CLAUDE_SESSION_ID") or None
554
+ project = os.environ.get("PROJECT_ENV", "")
555
+ account = os.environ.get("POST_ACCOUNT", "")
556
+ pending_id_str = os.environ.get("PENDING_ID_ENV", "")
557
+
558
+ # 2026-05-25: validate_or_register pass. The picker assignment (PICKED_STYLE +
559
+ # PICK_MODE) is sourced from the shell-level saps_pick_style call near the top
560
+ # of this script. USE mode coerces drift back to the assigned style; INVENT
561
+ # mode registers the new_style block (if the model shipped one) into
562
+ # engagement_styles_registry via /api/v1/engagement-styles/registry. Without
563
+ # this gate, run-reddit-threads.sh was the lone Reddit pipeline writing raw
564
+ # model output straight to posts.engagement_style, which let names like
565
+ # 'sensory_contrast' leak in without ever hitting the registry (caught
566
+ # 2026-05-25 during the engagement-style audit).
567
+ style = raw_style or None
568
+ try:
569
+ from engagement_styles import validate_or_register
570
+ picked_style = (os.environ.get("PICKED_STYLE_ENV") or "").strip() or None
571
+ pick_mode = (os.environ.get("PICK_MODE_ENV") or "").strip() or None
572
+ if raw_style:
573
+ new_style_block = parsed.get("new_style") if isinstance(parsed.get("new_style"), dict) else None
574
+ decision = {
575
+ "engagement_style": raw_style,
576
+ **({"new_style": new_style_block} if new_style_block else {}),
577
+ }
578
+ coerced_style, action = validate_or_register(
579
+ decision,
580
+ source_post={
581
+ "platform": "reddit",
582
+ "post_url": permalink or None,
583
+ "post_id": None,
584
+ "model": None,
585
+ },
586
+ assigned_style=picked_style,
587
+ assigned_mode=pick_mode,
588
+ )
589
+ if action == "coerced" and coerced_style and coerced_style != raw_style:
590
+ print(f"[engagement_style] coerced {raw_style!r} -> {coerced_style!r} "
591
+ f"(assigned={picked_style!r} mode={pick_mode!r})")
592
+ elif action == "registered":
593
+ print(f"[engagement_style] registered new style {coerced_style!r} "
594
+ f"into engagement_styles_registry")
595
+ elif action == "rejected":
596
+ print(f"[engagement_style] rejected {raw_style!r} (assigned={picked_style!r}); "
597
+ f"falling back to assigned")
598
+ coerced_style = picked_style
599
+ style = (coerced_style or picked_style or raw_style) or None
600
+ except Exception as e:
601
+ # Never block a posted thread on registry plumbing. Log and fall back
602
+ # to the raw model output.
603
+ print(f"[engagement_style] validate_or_register raised {e!r}; "
604
+ f"falling back to raw={raw_style!r}")
605
+ style = raw_style or None
606
+
607
+ if not permalink or not title:
608
+ print("[db-insert] SKIP — empty permalink or title in structured_output")
609
+ sys.exit(0)
610
+
611
+ # HTTP-only lane (2026-06-01): the authoritative post log goes through the
612
+ # s4l.ai API (POST /api/v1/posts). No DATABASE_URL, no psql, no db.get_conn().
613
+ # The endpoint dedups on (platform, thread_url) server-side and returns 409 with
614
+ # existing_post_id, which replaces the old SELECT idempotency guard. For an
615
+ # own-thread post, thread_url == our_url == permalink, so the (platform,
616
+ # thread_url) dedup is equivalent to the old (platform, our_url) check.
617
+ resp = api_post("/api/v1/posts", {
618
+ "platform": "reddit",
619
+ "thread_url": permalink,
620
+ "thread_author": account,
621
+ "thread_title": title,
622
+ "our_url": permalink,
623
+ "our_content": body,
624
+ "our_account": account,
625
+ "source_summary": summary,
626
+ "project": project,
627
+ "engagement_style": style,
628
+ "status": "active",
629
+ "claude_session_id": session,
630
+ }, ok_on_conflict=True)
631
+
632
+ if not resp.get("ok", True) and (resp.get("error") or {}).get("code") == "duplicate_thread":
633
+ existing_id = ((resp.get("error") or {}).get("details") or {}).get("existing_post_id")
634
+ print(f"[db-insert] SKIP — post {permalink} already in DB as id={existing_id}")
635
+ sys.exit(0)
636
+
637
+ post_id = (resp.get("data") or {}).get("post", {}).get("id")
638
+ print(f"[db-insert] OK — inserted posts.id={post_id} for {permalink}")
639
+
640
+ # If this run came from a pending_threads retry, mark the pending row posted
641
+ # so it stops being picked up by future retry cycles.
642
+ if pending_id_str:
643
+ try:
644
+ pt.mark_posted(pending_id=int(pending_id_str), post_id=post_id, permalink=permalink)
645
+ print(f"[pending] OK — pending_threads.id={pending_id_str} marked posted")
646
+ except Exception as e:
647
+ print(f"[pending] WARNING — mark_posted failed for id={pending_id_str}: {e}")
648
+
649
+ # Campaign wiring: post-submit edit pattern.
650
+ # Threads can't apply the suffix at submit time (Claude drives the browser
651
+ # directly via MCP), so we load active campaigns AFTER insert, roll the dice,
652
+ # and use reddit_browser.py edit-thread to append the suffix on the live post.
653
+ # Bumps the campaign counter only on a verified live edit (parallels
654
+ # post_reddit / engage_reddit / send_dm semantics).
655
+ import random, subprocess
656
+ from post_reddit import load_active_reddit_campaigns
657
+
658
+ active_campaigns = load_active_reddit_campaigns()
659
+ applied_campaign_ids = []
660
+ new_body = body
661
+ for camp in active_campaigns:
662
+ if random.random() < camp["sample_rate"]:
663
+ new_body = new_body + camp["suffix"]
664
+ applied_campaign_ids.append(camp["id"])
665
+
666
+ if applied_campaign_ids:
667
+ print(f"[campaign-thread] applying {applied_campaign_ids} (suffix to be appended via edit)")
668
+ rb = os.path.join(os.environ["REPO_DIR"], "scripts", "reddit_browser.py")
669
+ edit_proc = subprocess.run(
670
+ ["python3", rb, "edit-thread", permalink, new_body],
671
+ capture_output=True, text=True, timeout=120,
672
+ )
673
+ edit_ok = False
674
+ edit_payload = {}
675
+ try:
676
+ # reddit_browser.py edit-thread prints multi-line JSON via
677
+ # json.dumps(result, indent=2). Use raw_decode on the full stdout so
678
+ # we get the whole document, not just the final '}' line.
679
+ stdout_str = (edit_proc.stdout or "").strip()
680
+ edit_payload, _ = json.JSONDecoder().raw_decode(stdout_str)
681
+ edit_ok = edit_payload.get("ok") is True
682
+ except Exception as e:
683
+ edit_payload = {"_parse_error": f"{type(e).__name__}: {e}",
684
+ "_stdout_tail": (edit_proc.stdout or "")[-200:]}
685
+ if edit_ok:
686
+ from http_api import api_patch
687
+ api_patch(f"/api/v1/posts/{post_id}", {"our_content": new_body})
688
+ bump = os.path.join(os.environ["REPO_DIR"], "scripts", "campaign_bump.py")
689
+ for cid in applied_campaign_ids:
690
+ try:
691
+ subprocess.run(
692
+ ["python3", bump,
693
+ "--table", "posts", "--id", str(post_id),
694
+ "--campaign-id", str(cid)],
695
+ capture_output=True, text=True, timeout=15,
696
+ )
697
+ except Exception as e:
698
+ print(f"[campaign-thread] WARNING: campaign_bump failed (id={post_id} c={cid}): {e}")
699
+ print(f"[campaign-thread] OK — edit verified={edit_payload.get('verified')}, our_content updated, counters bumped")
700
+ else:
701
+ # Edit failed — leave the post untagged. The post is already live, so
702
+ # this is a degraded but not data-corrupting outcome. campaign_id stays
703
+ # NULL, so the row joins the control bucket for A/B purposes.
704
+ err = edit_payload.get("error") or edit_payload.get("_parse_error") or "unknown"
705
+ print(f"[campaign-thread] WARNING: edit-thread failed ({err}); post stays untagged. stderr={(edit_proc.stderr or '')[-200:]}")
706
+ else:
707
+ print("[campaign-thread] no active campaigns fired (or none active)")
708
+ PYEOF
709
+
710
+ elif [ -n "$ABORT_REASON" ] && [ "$ABORT_REASON" != "PARSE_ERROR" ]; then
711
+ echo "ABORTED: $ABORT_REASON" | tee -a "$LOG_FILE"
712
+ echo "PERMANENT_BLOCK signal from model: $PERMANENT_BLOCK" | tee -a "$LOG_FILE"
713
+
714
+ # Persist the abandoned draft to pending_threads so we don't lose the
715
+ # research/drafting work (the original 2026-05-01 r/AutoHotkey crash burned
716
+ # ~$24 because a fully-drafted post evaporated when the chrome MCP child
717
+ # died at the flair-click step). Skip persistence on permanent_block — that
718
+ # sub will never accept this post anyway.
719
+ if [ "$PERMANENT_BLOCK" != "1" ]; then
720
+ PARSED="$PARSED" \
721
+ CLAUDE_SESSION_ID="$CLAUDE_SESSION_ID" \
722
+ PROJECT_ENV="$PROJECT" \
723
+ SUBREDDIT_ENV="$SUBREDDIT" \
724
+ POST_ACCOUNT="$POST_ACCOUNT" \
725
+ ABORT_REASON_ENV="$ABORT_REASON" \
726
+ PENDING_ID_ENV="${PENDING_ID:-}" \
727
+ RETRY_MODE_ENV="${RETRY_MODE:-0}" \
728
+ REPO_DIR="$REPO_DIR" \
729
+ /usr/bin/python3 <<'PYEOF' 2>&1 | tee -a "$LOG_FILE" || true
730
+ import json, os, sys
731
+ sys.path.insert(0, os.path.join(os.environ["REPO_DIR"], "scripts"))
732
+ import pending_threads as pt
733
+
734
+ parsed = json.loads(os.environ.get("PARSED") or "{}")
735
+ title = (parsed.get("title") or "").strip()
736
+ body = (parsed.get("body") or "").strip()
737
+ abort_reason = os.environ.get("ABORT_REASON_ENV", "")
738
+ retry_mode = os.environ.get("RETRY_MODE_ENV", "0") == "1"
739
+ existing_pid = os.environ.get("PENDING_ID_ENV") or ""
740
+
741
+ if retry_mode and existing_pid:
742
+ # We were retrying an existing pending row. Bump its attempts; if it just
743
+ # crossed the abandon threshold (3), mark it abandoned so the retry loop
744
+ # stops picking it up.
745
+ pid = int(existing_pid)
746
+ pt.mark_aborted(
747
+ pending_id=pid,
748
+ abort_reason=abort_reason,
749
+ abort_stage="retry_attempt",
750
+ )
751
+ rec = pt.get(pid) or {}
752
+ if (rec.get("attempts") or 0) >= 3:
753
+ pt.abandon(pending_id=pid, reason=f"max_retries_exceeded ({abort_reason})")
754
+ print(f"[pending] ABANDON — id={pid} attempts={rec.get('attempts')} >= 3, no further retries")
755
+ else:
756
+ print(f"[pending] BUMP — id={pid} attempts={rec.get('attempts')} (will retry next cycle)")
757
+ elif not title or not body:
758
+ print("[pending] SKIP — no title/body in structured_output, nothing to persist")
759
+ else:
760
+ # Fresh-draft abort: save it for retry on next cycle.
761
+ pid = pt.create(
762
+ project=os.environ["PROJECT_ENV"],
763
+ subreddit=os.environ["SUBREDDIT_ENV"],
764
+ account=os.environ["POST_ACCOUNT"],
765
+ title=title,
766
+ body=body,
767
+ flair_target=parsed.get("flair_applied"),
768
+ engagement_style=parsed.get("engagement_style"),
769
+ topic_angle=parsed.get("topic_angle"),
770
+ source_summary=parsed.get("source_summary"),
771
+ claude_session_id=os.environ.get("CLAUDE_SESSION_ID") or None,
772
+ )
773
+ pt.mark_aborted(
774
+ pending_id=pid,
775
+ abort_reason=abort_reason,
776
+ abort_stage="post_attempt_1",
777
+ )
778
+ print(f"[pending] OK — saved draft id={pid} for retry on next thread cycle")
779
+ PYEOF
780
+ else
781
+ echo "[pending] SKIP — permanent_block=true, draft not retryable on this sub" | tee -a "$LOG_FILE"
782
+ # If we were retrying a pending row and the sub got permanently blocked,
783
+ # abandon the pending row so it stops blocking the queue.
784
+ if [ "$RETRY_MODE" = "1" ] && [ -n "${PENDING_ID:-}" ]; then
785
+ PENDING_ID_ENV="$PENDING_ID" \
786
+ ABORT_REASON_ENV="$ABORT_REASON" \
787
+ REPO_DIR="$REPO_DIR" \
788
+ /usr/bin/python3 -c "
789
+ import os, sys
790
+ sys.path.insert(0, os.path.join(os.environ['REPO_DIR'], 'scripts'))
791
+ import pending_threads as pt
792
+ pt.abandon(pending_id=int(os.environ['PENDING_ID_ENV']), reason='permanent_block: '+os.environ.get('ABORT_REASON_ENV',''))
793
+ print(f'[pending] ABANDON — id={os.environ[\"PENDING_ID_ENV\"]} due to permanent_block on retry')
794
+ " 2>&1 | tee -a "$LOG_FILE" || true
795
+ fi
796
+ fi
797
+ # Auto-block path:
798
+ # 1. PRIMARY: trust the model's permanent_block boolean from structured_output
799
+ # (added 2026-04-29). If true, add to thread_blocked unconditionally.
800
+ # 2. FALLBACK: regex match against abort_reason via _abort_is_permanent_block.
801
+ # Catches cases where the model forgot the field or is on an old prompt.
802
+ SUB_SLUG_ENV="$SUB_SLUG" \
803
+ ABORT_REASON_ENV="$ABORT_REASON" \
804
+ PERMANENT_BLOCK_ENV="$PERMANENT_BLOCK" \
805
+ PROJECT_NAME_ENV="${PROJECT_ENV:-}" \
806
+ REPO_DIR="$REPO_DIR" \
807
+ /usr/bin/python3 <<'PYEOF' 2>&1 | tee -a "$LOG_FILE" || true
808
+ import os, sys
809
+ sys.path.insert(0, os.path.join(os.environ["REPO_DIR"], "scripts"))
810
+ from post_reddit import mark_thread_blocked, _abort_is_permanent_block
811
+
812
+ sub = os.environ.get("SUB_SLUG_ENV", "")
813
+ reason = os.environ.get("ABORT_REASON_ENV", "")
814
+ project = os.environ.get("PROJECT_NAME_ENV") or None
815
+ explicit = os.environ.get("PERMANENT_BLOCK_ENV", "0") == "1"
816
+
817
+ if explicit:
818
+ # Model declared the block explicitly. Bypass the regex gate via force=True
819
+ # so the audit record still captures the model's verbatim abort_reason
820
+ # even when the text doesn't match _THREAD_BLOCK_PATTERNS.
821
+ mark_thread_blocked(sub, reason or "permanent_block=true", project=project, force=True)
822
+ print(f"[auto-block] r/{sub} added via explicit permanent_block=true from model (project={project!r})")
823
+ elif _abort_is_permanent_block(reason):
824
+ mark_thread_blocked(sub, reason, project=project)
825
+ print(f"[auto-block] r/{sub} added via regex fallback on abort_reason (project={project!r})")
826
+ else:
827
+ print(f"[auto-block] r/{sub} NOT auto-blocked (permanent_block=false, abort reason looks transient)")
828
+ PYEOF
829
+ else
830
+ echo "UNKNOWN OUTCOME (check JSON output above)" | tee -a "$LOG_FILE"
831
+ fi
832
+
833
+ # Surface this run in the dashboard's Job History under "Post Threads · Reddit".
834
+ # Script name `thread_reddit` is what bin/server.js classifyScript() matches.
835
+ ELAPSED=$(( $(date +%s) - RUN_START_EPOCH ))
836
+ if [ "$PERMALINK" != "null" ] && [ "$PERMALINK" != "" ] && [ "$PERMALINK" != "PARSE_ERROR" ]; then
837
+ POSTED_CT=1; SKIPPED_CT=0; FAILED_CT=0
838
+ elif [ -n "$ABORT_REASON" ] && [ "$ABORT_REASON" != "PARSE_ERROR" ]; then
839
+ POSTED_CT=0; SKIPPED_CT=1; FAILED_CT=0
840
+ else
841
+ POSTED_CT=0; SKIPPED_CT=0; FAILED_CT=1
842
+ fi
843
+ _COST=$(/usr/bin/python3 "$REPO_DIR/scripts/get_run_cost.py" --since "$RUN_START_EPOCH" --scripts "run-reddit-threads" 2>/dev/null || echo "0.0000")
844
+ /usr/bin/python3 "$REPO_DIR/scripts/log_run.py" --script "thread_reddit" --posted "$POSTED_CT" --skipped "$SKIPPED_CT" --failed "$FAILED_CT" --cost "$_COST" --elapsed "$ELAPSED" || true
845
+
846
+ echo "=== Run complete: $(date) ===" | tee -a "$LOG_FILE"
847
+ find "$LOG_DIR" -name "run-reddit-threads-*.log" -mtime +14 -delete 2>/dev/null || true