@m13v/s4l 1.6.197-rc.7

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 +1314 -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 +497 -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,1311 @@
1
+ #!/usr/bin/env python3
2
+ """GitHub Issues posting orchestrator with momentum-gated candidate selection.
3
+
4
+ Two-phase design (consolidated 2026-04-24, replacing the short-lived
5
+ run_github_cycle.py):
6
+
7
+ Phase 1: search project topics across N seeds, snapshot T0 comment + reaction
8
+ counts. The originating seed is stamped on every candidate so the
9
+ feedback loop (top_search_topics.py) gets fed back into the next run.
10
+ Sleep --sleep seconds (default 600).
11
+ Phase 2a: re-poll every candidate, compute delta_score = 3*Δcomments + 2*Δreactions.
12
+ Phase 2b: adaptive cap (CAP_DEFAULT, bumped to CAP_BUMPED when >= HIGH_DELTA_BUMP
13
+ candidates clear DELTA_THRESHOLD), Claude only drafts comments — no
14
+ Bash tools, no in-flight searches, single JSON response. Python posts
15
+ via gh and persists everything (search_topic, language, engagement_style,
16
+ claude_session_id) to the posts table.
17
+
18
+ Why a single Python orchestrator instead of letting Claude search itself:
19
+ the pre-filter cuts Claude's tool budget to zero, the momentum gate suppresses
20
+ posts on stale threads, and the seed-per-candidate signal closes the
21
+ top_search_topics feedback loop. Claude returns one JSON in one shot.
22
+
23
+ Usage:
24
+ python3 scripts/post_github.py
25
+ python3 scripts/post_github.py --sleep 60 --dry-run # quick dev
26
+ python3 scripts/post_github.py --project Fazm # force project
27
+ python3 scripts/post_github.py --limit 5 # caps adaptive cap
28
+ """
29
+
30
+ import argparse
31
+ import atexit
32
+ import json
33
+ import os
34
+ import random
35
+ import re
36
+ import signal
37
+ import subprocess
38
+ import sys
39
+ import time
40
+ import uuid
41
+ from datetime import datetime
42
+
43
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
44
+ from http_api import api_get
45
+ import pick_project
46
+ from author_history_block import render as _render_author_history
47
+ from project_topics import topics_for_project
48
+
49
+ # ---------------------------------------------------------------------------
50
+ # Run-summary safety net (atexit + SIGTERM/SIGHUP handlers).
51
+ # ---------------------------------------------------------------------------
52
+ # Mirrors the bash-side fix shipped to run-reddit-search.sh / run-twitter-cycle.sh
53
+ # / run-linkedin.sh: under SIGTERM the orchestrator can land between a
54
+ # successful gh-comment post (`posted += 1`) and the inline log_run.py call
55
+ # at the bottom of main(), silently dropping the run from run_monitor.log
56
+ # while the `posts` table already shows the comment.
57
+ #
58
+ # Mechanism:
59
+ # - _RUN_STATE is a module-level dict main() updates as it runs
60
+ # (run_start, cost, posted, skipped, failed).
61
+ # - _emit_run_summary_oneshot() shells out to scripts/log_run.py with
62
+ # whatever state is current. Idempotent via _RUN_STATE['emitted'].
63
+ # - atexit.register catches normal exits + uncaught exceptions.
64
+ # - signal.signal() converts SIGTERM/SIGHUP into a sys.exit(128+signum)
65
+ # call so atexit handlers actually run (Python's default SIGTERM
66
+ # handler is to exit immediately, BYPASSING atexit).
67
+ # - SIGINT / KeyboardInterrupt: Python's default already raises an
68
+ # exception that unwinds through atexit, no extra wiring needed.
69
+ #
70
+ # Each existing inline log_run.py call (Claude failure path, success path)
71
+ # sets _RUN_STATE['emitted'] = True after running so the atexit handler
72
+ # becomes a no-op for those branches and we don't double-write.
73
+ _RUN_STATE = {
74
+ "emitted": False,
75
+ "run_start": None,
76
+ "posted": 0,
77
+ "skipped": 0,
78
+ "failed": 0,
79
+ "cost": 0.0,
80
+ }
81
+
82
+
83
+ def _emit_run_summary_oneshot():
84
+ if _RUN_STATE["emitted"] or _RUN_STATE["run_start"] is None:
85
+ return
86
+ _RUN_STATE["emitted"] = True
87
+ elapsed = int(time.time() - _RUN_STATE["run_start"])
88
+ try:
89
+ subprocess.run(
90
+ [
91
+ PYTHON, os.path.join(os.path.dirname(os.path.abspath(__file__)), "log_run.py"),
92
+ "--script", "post_github",
93
+ "--posted", str(_RUN_STATE["posted"]),
94
+ "--skipped", str(_RUN_STATE["skipped"]),
95
+ "--failed", str(_RUN_STATE["failed"]),
96
+ "--cost", f"{_RUN_STATE['cost']:.4f}",
97
+ "--elapsed", str(elapsed),
98
+ ],
99
+ timeout=15,
100
+ check=False,
101
+ )
102
+ except Exception:
103
+ # Trap context: never raise from the safety net. Better to lose this
104
+ # one summary line than to crash a shutdown sequence that might be
105
+ # holding a browser lock or DB connection that other peers need.
106
+ pass
107
+
108
+
109
+ def _signal_to_exit(signum, _frame):
110
+ # Convert the signal into a normal-looking exit so atexit fires.
111
+ sys.exit(128 + signum)
112
+
113
+
114
+ atexit.register(_emit_run_summary_oneshot)
115
+ # Only install handlers when running as the main entry point so importing
116
+ # post_github (e.g. for unit tests, or when SCRIPTS adds it to PYTHONPATH)
117
+ # doesn't override the parent process's signal handling.
118
+ if __name__ == "__main__" or os.environ.get("POST_GITHUB_INSTALL_TRAPS") == "1":
119
+ for _sig in (signal.SIGTERM, signal.SIGHUP):
120
+ try:
121
+ signal.signal(_sig, _signal_to_exit)
122
+ except (ValueError, OSError):
123
+ # Non-main-thread import or unsupported signal: skip silently.
124
+ pass
125
+
126
+ from engagement_styles import (
127
+ VALID_STYLES, get_styles_prompt, get_content_rules, get_anti_patterns,
128
+ validate_or_register, pick_style_for_post,
129
+ )
130
+ # Audience-page routing: tells Claude which curated landing pages exist for the
131
+ # project so it can bake a deep URL (e.g. https://s4l.ai/ghostwriting) into the
132
+ # draft when the issue topic matches. See scripts/audience_pages.py + the
133
+ # landing_pages.audience_pages block in config.json.
134
+ from audience_pages import (
135
+ prompt_block as _audience_prompt_block,
136
+ classify_url_as_audience_page as _audience_classify_url,
137
+ )
138
+
139
+ REPO_DIR = os.path.expanduser("~/social-autoposter")
140
+ SCRIPTS = os.path.join(REPO_DIR, "scripts")
141
+ CONFIG_PATH = os.path.join(REPO_DIR, "config.json")
142
+ SKILL_FILE = os.path.join(REPO_DIR, "SKILL.md")
143
+ GITHUB_TOOLS = os.path.join(SCRIPTS, "github_tools.py")
144
+ RUN_CLAUDE = os.path.join(SCRIPTS, "run_claude.sh")
145
+
146
+ # Interpreter every child subprocess must run under. A bare PYTHON resolved
147
+ # to the user's system python, which lacks the pipeline deps that live only in
148
+ # the owned uv runtime — the same fresh-box failure class that broke the Twitter
149
+ # poster (Karol, 2026-06-22). The GitHub rail posts via the REST API (no browser,
150
+ # so no Playwright dep), but its util/DB children still need the owned venv, so
151
+ # pin the interpreter here too. Honor S4L_PYTHON (set by the launchd plist),
152
+ # else sys.executable; never the literal PYTHON.
153
+ PYTHON = os.environ.get("S4L_PYTHON") or sys.executable
154
+ os.environ["S4L_PYTHON"] = PYTHON
155
+
156
+ # Momentum tunables. Edit here, not at call sites.
157
+ DELTA_THRESHOLD = 1.0
158
+ HIGH_DELTA_BUMP = 3
159
+ CAP_DEFAULT = 1
160
+ CAP_BUMPED = 3
161
+ CLAUDE_CANDIDATE_LIMIT = 8 # show top N to Claude
162
+ SEARCH_PER_TOPIC = 5 # gh search --limit per topic
163
+ MAX_TOPICS_PER_PROJECT = 6
164
+
165
+ # Maintainer-just-spoke gate. authorAssociation values that count as "maintainer".
166
+ # If the most recent commenter on a candidate issue is one of these, we drop the
167
+ # candidate to avoid piling on a maintainer who just set direction (root cause of
168
+ # the antiwork/gumroad LOW_QUALITY minimization, posts #21826 + #22200).
169
+ MAINTAINER_ASSOCIATIONS = {"OWNER", "MEMBER", "COLLABORATOR"}
170
+
171
+ # Relevance gate. Claude returns relevance:0..3 per draft; we drop everything
172
+ # below this floor before posting. 2 = "project's tools/audience could plausibly
173
+ # help here." 0/1 = off-domain. Tunable.
174
+ MIN_RELEVANCE = 2
175
+
176
+
177
+ def log(msg):
178
+ print(f"[{datetime.now().strftime('%H:%M:%S')}] [post_github] {msg}", flush=True)
179
+
180
+
181
+ def load_config():
182
+ with open(CONFIG_PATH) as f:
183
+ return json.load(f)
184
+
185
+
186
+ # ---------- Project picking & context ---------------------------------------
187
+
188
+ def get_top_performers(project_name, platform="github"):
189
+ try:
190
+ result = subprocess.run(
191
+ [PYTHON, os.path.join(SCRIPTS, "top_performers.py"),
192
+ "--platform", platform, "--project", project_name],
193
+ capture_output=True, text=True, timeout=15,
194
+ )
195
+ if result.returncode == 0:
196
+ return result.stdout.strip()
197
+ except Exception:
198
+ pass
199
+ return ""
200
+
201
+
202
+ def get_top_search_topics(project_name, platform="github", limit=8, window_days=30):
203
+ """Best-performing search_topic seeds for this project on this platform.
204
+ Empty string if no data yet. Mirrors post_reddit.get_top_search_topics."""
205
+ try:
206
+ result = subprocess.run(
207
+ [PYTHON, os.path.join(SCRIPTS, "top_search_topics.py"),
208
+ "--project", project_name, "--platform", platform,
209
+ "--window-days", str(window_days), "--limit", str(limit)],
210
+ capture_output=True, text=True, timeout=15,
211
+ )
212
+ if result.returncode == 0:
213
+ return result.stdout.strip()
214
+ except Exception:
215
+ pass
216
+ return ""
217
+
218
+
219
+ def get_recent_comments(limit=5):
220
+ """Last N github comments by id DESC. Tuple form `(id, our_content)` so
221
+ the generation_trace audit row can store the IDs alongside the text (no
222
+ duplication: the text is already in the posts table, the IDs let us
223
+ reverse-link). Backward-compat note: this used to return a plain list
224
+ of strings; callers that consume `recent_comments` for prompt-building
225
+ were updated in the same change."""
226
+ resp = api_get("/api/v1/posts", query={
227
+ "platform": "github",
228
+ "order_by": "id",
229
+ "order_dir": "desc",
230
+ "limit": int(limit),
231
+ })
232
+ rows = ((resp or {}).get("data") or {}).get("posts") or []
233
+ # Return as list of (id, content) tuples. The caller-side conversion
234
+ # to a flat string list for prompt-building is one-line below in main().
235
+ return [(int(r["id"]), r.get("our_content") or "") for r in rows]
236
+
237
+
238
+ # Generation trace plumbing lives in scripts/generation_trace.py so the
239
+ # github / reddit / twitter pipelines all write the same shape. See that
240
+ # module for the shape contract and migrations/2026-05-12_generation_trace.sql
241
+ # for the JSONB column definition.
242
+ import generation_trace as _gen_trace
243
+
244
+
245
+ def _angle_str(v):
246
+ if isinstance(v, str):
247
+ return v.strip()
248
+ if isinstance(v, dict):
249
+ return "; ".join(f"{k}: {_angle_str(x)}" for k, x in v.items() if x)
250
+ if isinstance(v, (list, tuple)):
251
+ return ", ".join(_angle_str(x) for x in v if x)
252
+ return str(v) if v else ""
253
+
254
+
255
+ def build_content_angle(project, config):
256
+ """Rich angle: prefer content_angle override, otherwise compose from
257
+ description / differentiator / icp / setup / messaging / voice.
258
+
259
+ Always appends the project's audience-pages block (when configured) so the
260
+ draft prompt knows which curated landing pages it should link to for
261
+ topic-matched issues.
262
+ """
263
+ if project.get("content_angle"):
264
+ base = project["content_angle"]
265
+ else:
266
+ parts = []
267
+ for key in ("description", "differentiator", "icp", "setup"):
268
+ s = _angle_str(project.get(key))
269
+ if s:
270
+ parts.append(s)
271
+ messaging = project.get("messaging", {}) or {}
272
+ for key in ("lead_with_pain", "solution", "proof"):
273
+ s = _angle_str(messaging.get(key))
274
+ if s:
275
+ parts.append(s)
276
+ voice = project.get("voice", {}) or {}
277
+ if voice.get("tone"):
278
+ parts.append(f"Voice: {voice['tone']}")
279
+ if voice.get("never"):
280
+ parts.append("Never: " + "; ".join(voice["never"]))
281
+ examples = voice.get("examples") or voice.get("examples_good") or []
282
+ if examples:
283
+ parts.append("Voice examples: " + " | ".join(examples[:3]))
284
+ base = " ".join(parts) if parts else config.get("content_angle", "")
285
+
286
+ try:
287
+ ap_block = _audience_prompt_block(project.get("name") or "")
288
+ except Exception:
289
+ ap_block = ""
290
+ if ap_block:
291
+ return (base + "\n\n" + ap_block).strip() if base else ap_block.strip()
292
+ return base
293
+
294
+
295
+ # ---------- Phase 1 / 2 momentum helpers ------------------------------------
296
+
297
+ def gh_search(query, limit=SEARCH_PER_TOPIC):
298
+ try:
299
+ out = subprocess.check_output(
300
+ [PYTHON, GITHUB_TOOLS, "search", query, "--limit", str(limit)],
301
+ text=True, timeout=45,
302
+ )
303
+ items = json.loads(out)
304
+ except Exception as e:
305
+ log(f" gh_search failed for '{query}': {e}")
306
+ return []
307
+ return [i for i in items if not i.get("already_posted")]
308
+
309
+
310
+ def gh_view_counts(repo, number):
311
+ """Return dict{comment_count, reaction_count, title, body, author, url,
312
+ maintainer_last_speaker, last_commenter, last_comment_assoc} or None if the
313
+ issue is no longer open / unfetchable.
314
+
315
+ `gh issue view --json comments` returns each comment with `authorAssociation`
316
+ (OWNER/MEMBER/COLLABORATOR/CONTRIBUTOR/NONE/...) and `createdAt`. We use the
317
+ most recent comment to detect "maintainer just spoke" so phase 1 can drop
318
+ those candidates without an extra API call."""
319
+ try:
320
+ out = subprocess.check_output(
321
+ ["gh", "issue", "view", str(number), "-R", repo,
322
+ "--json", "title,body,author,url,comments,reactionGroups,state"],
323
+ text=True, timeout=30, stderr=subprocess.STDOUT,
324
+ )
325
+ data = json.loads(out)
326
+ except Exception:
327
+ return None
328
+ if data.get("state") and data["state"].lower() != "open":
329
+ return None
330
+ comments = data.get("comments") or []
331
+ reaction_count = 0
332
+ for g in data.get("reactionGroups") or []:
333
+ reaction_count += int(
334
+ (g.get("users") or {}).get("totalCount", 0) or g.get("totalCount", 0) or 0
335
+ )
336
+
337
+ # Maintainer-just-spoke gate. Sort comments by createdAt desc, look at the
338
+ # most recent one (regardless of timing). If the issue's last word came from
339
+ # someone with push access, the thread is being driven and we shouldn't pile
340
+ # on. The OP's authorAssociation is checked separately (issue.author isn't
341
+ # included in `comments`, only in the top-level `author` field).
342
+ maintainer_last_speaker = False
343
+ last_commenter = ""
344
+ last_comment_assoc = ""
345
+ if comments:
346
+ try:
347
+ sorted_c = sorted(
348
+ comments,
349
+ key=lambda c: c.get("createdAt", "") or "",
350
+ reverse=True,
351
+ )
352
+ last = sorted_c[0]
353
+ last_commenter = (last.get("author") or {}).get("login", "") or ""
354
+ last_comment_assoc = (last.get("authorAssociation") or "").upper()
355
+ if last_comment_assoc in MAINTAINER_ASSOCIATIONS:
356
+ maintainer_last_speaker = True
357
+ except Exception:
358
+ pass
359
+
360
+ return {
361
+ "comment_count": len(comments),
362
+ "reaction_count": reaction_count,
363
+ "title": data.get("title", ""),
364
+ "body": (data.get("body") or ""),
365
+ "author": (data.get("author") or {}).get("login", ""),
366
+ "url": data.get("url", ""),
367
+ "maintainer_last_speaker": maintainer_last_speaker,
368
+ "last_commenter": last_commenter,
369
+ "last_comment_assoc": last_comment_assoc,
370
+ }
371
+
372
+
373
+ def delta_score(c0, r0, c1, r1):
374
+ return 3.0 * max(c1 - c0, 0) + 2.0 * max(r1 - r0, 0)
375
+
376
+
377
+ def parse_repo_number(url):
378
+ m = re.match(r"https?://github\.com/([^/]+)/([^/]+)/(?:issues|pull)/(\d+)", url or "")
379
+ if not m:
380
+ return None, None
381
+ return f"{m.group(1)}/{m.group(2)}", int(m.group(3))
382
+
383
+
384
+ def parse_issue_url(url):
385
+ if not url:
386
+ return None, None, None
387
+ m = re.search(r"github\.com/([^/]+)/([^/]+)/(?:issues|pull)/(\d+)", url)
388
+ if not m:
389
+ return None, None, None
390
+ return m.group(1), m.group(2), int(m.group(3))
391
+
392
+
393
+ # ---------- Prompt -----------------------------------------------------------
394
+
395
+ def build_prompt(project, config, candidates, cap, top_report, recent_comments,
396
+ top_topics_report="", style_assignment=None):
397
+ content_angle = build_content_angle(project, config)
398
+ excluded_repos = config.get("exclusions", {}).get("github_repos", [])
399
+ excluded_authors = config.get("exclusions", {}).get("authors", [])
400
+ # Style enforcement: when style_assignment is provided the JSON example
401
+ # pins the assigned style name into engagement_style so the model cannot
402
+ # silently substitute a different label. INVENT mode (style=None) still
403
+ # leaves engagement_style up to the model but it's expected to fill
404
+ # new_style with the registration block. Without an assignment the
405
+ # legacy menu wording is preserved for backward compatibility.
406
+ _assigned_style_name = (style_assignment or {}).get("style")
407
+ _assigned_mode = (style_assignment or {}).get("mode")
408
+ if _assigned_style_name:
409
+ # USE mode: pin literal name.
410
+ _style_field_example = _assigned_style_name
411
+ elif _assigned_mode == "invent":
412
+ # INVENT mode: the model writes a new snake_case name and fills new_style.
413
+ _style_field_example = "<your invented snake_case name>"
414
+ else:
415
+ # No assignment: legacy menu mode.
416
+ _style_field_example = (
417
+ f"<one of {', '.join(sorted(VALID_STYLES))}, or your invented snake_case name>"
418
+ )
419
+
420
+ cand_block = []
421
+ for i, c in enumerate(candidates, 1):
422
+ seed_line = f"seed: {c['search_topic']}\n" if c.get("search_topic") else ""
423
+ last_speaker_line = ""
424
+ if c.get("last_commenter"):
425
+ last_speaker_line = (
426
+ f"last_commenter: {c['last_commenter']} "
427
+ f"({c.get('last_comment_assoc') or 'NONE'})\n"
428
+ )
429
+ history_block = ""
430
+ try:
431
+ _hb = _render_author_history(
432
+ "github", c.get("author") or "", days=30, limit=5
433
+ )
434
+ if _hb:
435
+ history_block = _hb + "\n"
436
+ except Exception:
437
+ pass
438
+ cand_block.append(
439
+ f"--- #{i} {c['repo']}#{c['number']} delta={c['delta_score']:.1f} "
440
+ f"(cm {c['comment_count_t0']}->{c['comment_count_t1']}, "
441
+ f"rx {c['reaction_count_t0']}->{c['reaction_count_t1']}) ---\n"
442
+ f"{seed_line}"
443
+ f"{last_speaker_line}"
444
+ f"title: {c['title']}\n"
445
+ f"author: {c['author']}\n"
446
+ f"url: {c['url']}\n"
447
+ f"body: {c['body']}\n"
448
+ f"{history_block}"
449
+ )
450
+ candidates_text = "\n".join(cand_block)
451
+
452
+ recent_ctx = ""
453
+ if recent_comments:
454
+ # recent_comments is now a list of (id, content) tuples (2026-05-12
455
+ # change to support generation_trace audit). Accept both shapes
456
+ # here so any caller still passing plain strings keeps working.
457
+ def _extract(item):
458
+ if isinstance(item, (list, tuple)) and len(item) >= 2:
459
+ return item[1]
460
+ return item
461
+ snippets = "\n".join(
462
+ f" - {_extract(c)}"
463
+ for c in recent_comments
464
+ if _extract(c)
465
+ )
466
+ if snippets:
467
+ recent_ctx = f"""
468
+ Your last {len(recent_comments)} GitHub comments (don't repeat talking points):
469
+ {snippets}
470
+ """
471
+
472
+ top_ctx = ""
473
+ if top_report:
474
+ lines = top_report.split("\n")[:30]
475
+ top_ctx = f"""
476
+ ## Feedback from past performance:
477
+ {chr(10).join(lines)}
478
+ """
479
+
480
+ top_topics_ctx = ""
481
+ if top_topics_report:
482
+ top_topics_ctx = f"""
483
+ ## Past top-performing search topics (sorted by clicks DESC first, then composite-scored: clicks*100 + comments*3 + upvotes)
484
+ CLICKS ARE THE PRIORITY SIGNAL. Any topic with `clicks > 0` is GOLD TIER, clicks
485
+ are the only metric that proves our reply drove someone to actually visit the
486
+ project's link. Comments and upvotes are vanity. If an issue's seed matches a
487
+ gold-tier topic, prefer that issue; mimic ITS framing (repo type, language,
488
+ issue keyword cluster) FIRST before falling back to other styles. Optimize the
489
+ entire pipeline for clicks; everything else is leading indicators.
490
+
491
+ {top_topics_report}
492
+
493
+ If none of the top topics match this run's candidates, prefer issues with
494
+ strong delta scores. New topics with 0 clicks are fine, we still need to
495
+ explore, but a gold-tier topic that fits should beat any unproven topic.
496
+ """
497
+
498
+ project_name = project["name"]
499
+ min_relevance = MIN_RELEVANCE
500
+ project_github = (project.get("github") or "").strip()
501
+ github_repo_block = (
502
+ f"\n\n## Our public repo for self-reply links\n{project_github}\n"
503
+ f"When the self-reply policy below applies, the github blob URL MUST live "
504
+ f"under this repo. Pick a real path you have reason to believe exists; if "
505
+ f"you're unsure, default to the repo root or a top-level README rather "
506
+ f"than inventing a deep path."
507
+ if project_github else ""
508
+ )
509
+ return f"""You are the Social Autoposter drafting GitHub issue comments for project {project_name}.
510
+
511
+ Read {SKILL_FILE} for content rules (no em dashes, anti-AI tells, voice).
512
+
513
+ ## Project context
514
+ {content_angle}{github_repo_block}
515
+
516
+ ## Pre-filtered candidates (top {len(candidates)} by recent engagement delta)
517
+
518
+ Each candidate already cleared exclusion + already-posted filtering. The seed
519
+ shown is the search_topic that surfaced the issue, echo it back verbatim in
520
+ "search_topic" so we can score which seeds produce engagement.
521
+
522
+ {candidates_text}
523
+ {recent_ctx}{top_ctx}{top_topics_ctx}
524
+ {get_styles_prompt("github", context="posting", assignment=style_assignment)}
525
+
526
+ ## Targeting
527
+ - Best topics: Agents, Accessibility, Voice/ASR, Tool Use. Prioritize when present.
528
+ - Exclusions are already filtered, but for reference:
529
+ - Excluded repos: {', '.join(excluded_repos) if excluded_repos else '(none)'}
530
+ - Excluded authors: {', '.join(excluded_authors) if excluded_authors else '(none)'}
531
+
532
+ ## Comment style (parent comment)
533
+ - Lead with the pain you hit, then your fix. "the token overhead is brutal" beats "here is how to optimize".
534
+ - Conversational, no markdown headings, no code blocks unless tiny.
535
+ - 400-600 chars. Short enough to read, long enough to show concrete observation, not generic advice.
536
+ - File names FROM THE MAINTAINER'S ISSUE OR REPO are great evidence you read it. File names from OUR OWN codebase do NOT belong in the parent comment, save them for the self-reply (see below) where they ride a real URL. Bare filenames from our repos with no URL ("server.rs, ChatToolExecutor.swift") are the spam shape that gets us moderated; never do that.
537
+ - NO links in the parent comment. The optional self-reply is where one link goes.
538
+
539
+ ## Self-reply policy (optional follow-up with ONE github link)
540
+
541
+ Each post may carry an OPTIONAL `self_reply_text` that posts as a separate comment a minute or two after the parent. Its job is to point the maintainer at a specific, public file in one of OUR repos that demonstrates a concrete claim the parent comment made.
542
+
543
+ The self-reply ONLY fires when ALL THREE hold:
544
+ 1. Your parent comment makes a specific technical claim ("we ran into X and ended up doing Y") that a single file in our repo would back up.
545
+ 2. You can point to a REAL https://github.com/ blob URL with a plausible path. Use the project's public repo (see "Project context" above for the `github` field).
546
+ 3. The file is genuinely relevant to the maintainer's question, not a tangentially-related drop.
547
+
548
+ If ANY of the three is missing, set `self_reply_text: null`. Quiet bundles are healthy. A forced "here is some code" reply with a bare filename or an off-topic file is the exact pattern that gets us moderated; we'd rather skip the link than post a weak one.
549
+
550
+ Shape when present (100-220 chars, ONE URL, no markdown):
551
+ "our X that handles Y: https://github.com/<our-org>/<our-repo>/blob/main/<path>"
552
+ or just the natural framing + URL. No tagline, no signoff, no project pitch.
553
+
554
+ ## Relevance scoring (REQUIRED, drop anything < {min_relevance})
555
+
556
+ For every candidate you draft, also score `relevance` 0..3 vs. the project above:
557
+ - 3 = direct fit. The issue's problem is exactly what {project_name} solves.
558
+ - 2 = relevant. The project's tools, audience, or problem-space could plausibly help.
559
+ - 1 = tangential. Same abstractions, different problem (e.g. caching advice on a
560
+ copy-variation issue). Don't post these.
561
+ - 0 = unrelated. Don't post these.
562
+
563
+ Scoring < {min_relevance} must go to "skipped" with reason "low_relevance".
564
+ The pipeline drops these automatically; do not try to bypass.
565
+
566
+ ## Anti-spam guardrails (skip a candidate if ANY apply)
567
+
568
+ Recent strikes were minimized as LOW_QUALITY because we drafted "expert"
569
+ takes that ignored what the maintainer just said. Skip when:
570
+ - `last_commenter` is OWNER/MEMBER/COLLABORATOR (already pre-filtered, but
571
+ re-confirm: if the maintainer's most recent message sets a clear direction,
572
+ don't pile on with a counter-take).
573
+ - The issue is about content/copy/ux/business decisions and you'd have to
574
+ pivot to architecture/perf/caching to have something to say.
575
+ - You'd have to manufacture experience ("I ran this in production at scale...",
576
+ "I've seen this play out dozens of times...") to fill the 400-char budget.
577
+ - Other recent commenters are obviously pitching their own tool. You'll be
578
+ grouped with them by the maintainer.
579
+ - You'd cite a precedent you can't actually link to (Apple ?ppid, Stripe X,
580
+ Shopify Y, etc.). Hand-wavy precedent name-drops read as fake-expert.
581
+
582
+ ## YOUR JOB
583
+
584
+ Pick UP TO {cap} candidates worth commenting on and draft one comment for each.
585
+
586
+ ZERO POSTS IS A VALID, FREQUENTLY CORRECT OUTCOME. Returning `"posts": []` and
587
+ listing the candidates in `"skipped"` is preferred over forcing a comment on a
588
+ mediocre fit. The pipeline runs every cycle; quiet cycles are healthy.
589
+
590
+ ## Content rules
591
+ {get_content_rules("github")}
592
+
593
+ {get_anti_patterns()}
594
+
595
+ ## OUTPUT FORMAT
596
+
597
+ Return ONLY a single JSON object. No prose, no markdown fencing, no Bash calls:
598
+
599
+ {{
600
+ "posts": [
601
+ {{
602
+ "repo": "<owner/repo>",
603
+ "number": <issue number>,
604
+ "thread_url": "<issue url>",
605
+ "thread_title": "<issue title>",
606
+ "thread_author": "<issue author>",
607
+ "matched_project": "{project_name}",
608
+ "engagement_style": "{_style_field_example}",
609
+ "new_style": null,
610
+ "search_topic": "<the seed from the candidate block, copied verbatim>",
611
+ "language": "<ISO 639-1 code matching the issue language: en, ja, zh, es, ...>",
612
+ "relevance": <int 0..3, see scoring rules above; must be >= {min_relevance} to post>,
613
+ "relevance_rationale": "<one short sentence: why this score>",
614
+ "comment_text": "<the actual comment to post, 400-600 chars, NO links>",
615
+ "self_reply_text": <null OR a 100-220 char follow-up containing exactly ONE https://github.com/... blob URL into one of OUR public repos. See "Self-reply policy" above. Default to null unless all three conditions hold.>
616
+ }}
617
+ ],
618
+ "skipped": [
619
+ {{ "url": "<issue url>", "reason": "<short reason; use 'low_relevance' when relevance < {min_relevance}>" }}
620
+ ]
621
+ }}
622
+
623
+ If, and ONLY if, none of the listed styles fits, you may invent one. Set
624
+ "engagement_style" to your snake_case name AND replace `"new_style": null` with
625
+ `{{"description": "...", "example": "...", "note": "...", "why_existing_didnt_fit": "..."}}`.
626
+ Inventing should be rare; prefer an existing style if it's even 80% right.
627
+
628
+ CRITICAL: Do NOT call gh, Bash, or any tool. The orchestrator already searched
629
+ and viewed; just return the JSON.
630
+ """
631
+
632
+
633
+ # ---------- Claude one-shot (no tools needed since pre-filter is in Python) -
634
+
635
+ def run_claude(prompt, timeout=900):
636
+ """One-shot non-streaming Claude via run_claude.sh wrapper. Returns
637
+ (ok, raw_stdout, usage_dict)."""
638
+ usage = {"cost_usd": 0.0, "input_tokens": 0, "output_tokens": 0,
639
+ "cache_read": 0, "cache_create": 0}
640
+ cmd = [RUN_CLAUDE, "post_github",
641
+ "--strict-mcp-config",
642
+ "--mcp-config", os.path.expanduser("~/.claude/browser-agent-configs/no-agents-mcp.json"),
643
+ "-p", "--output-format", "json", prompt]
644
+ env = os.environ.copy()
645
+ env.pop("ANTHROPIC_API_KEY", None)
646
+ try:
647
+ proc = subprocess.run(cmd, env=env, capture_output=True, text=True, timeout=timeout)
648
+ except subprocess.TimeoutExpired:
649
+ return False, "TIMEOUT", usage
650
+ try:
651
+ outer = json.loads(proc.stdout)
652
+ usage["cost_usd"] = float(outer.get("total_cost_usd", 0.0) or 0.0)
653
+ u = outer.get("usage", {}) or {}
654
+ usage["input_tokens"] = int(u.get("input_tokens", 0) or 0)
655
+ usage["output_tokens"] = int(u.get("output_tokens", 0) or 0)
656
+ usage["cache_read"] = int(u.get("cache_read_input_tokens", 0) or 0)
657
+ usage["cache_create"] = int(u.get("cache_creation_input_tokens", 0) or 0)
658
+ except (json.JSONDecodeError, TypeError, ValueError):
659
+ pass
660
+ return proc.returncode == 0, proc.stdout, usage
661
+
662
+
663
+ def parse_claude_json(output):
664
+ """Extract the inner JSON object from --output-format json envelope."""
665
+ try:
666
+ outer = json.loads(output)
667
+ result = outer.get("result", "") if isinstance(outer, dict) else str(outer)
668
+ except Exception:
669
+ result = output
670
+ start = result.find("{")
671
+ if start < 0:
672
+ return None
673
+ depth, in_str, esc, end = 0, False, False, -1
674
+ for i in range(start, len(result)):
675
+ ch = result[i]
676
+ if in_str:
677
+ if esc:
678
+ esc = False
679
+ elif ch == "\\":
680
+ esc = True
681
+ elif ch == '"':
682
+ in_str = False
683
+ continue
684
+ if ch == '"':
685
+ in_str = True
686
+ elif ch == "{":
687
+ depth += 1
688
+ elif ch == "}":
689
+ depth -= 1
690
+ if depth == 0:
691
+ end = i
692
+ break
693
+ if end < 0:
694
+ return None
695
+ try:
696
+ return json.loads(result[start:end + 1])
697
+ except Exception:
698
+ return None
699
+
700
+
701
+ # ---------- Posting + logging ------------------------------------------------
702
+
703
+ def post_comment(owner, repo, number, body):
704
+ try:
705
+ out = subprocess.check_output(
706
+ ["gh", "issue", "comment", str(number), "-R", f"{owner}/{repo}", "--body", body],
707
+ text=True, timeout=60, stderr=subprocess.STDOUT,
708
+ )
709
+ url = None
710
+ for line in out.strip().splitlines():
711
+ if line.startswith("https://github.com"):
712
+ url = line.strip()
713
+ break
714
+ return True, url
715
+ except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e:
716
+ err = e.output if hasattr(e, "output") and e.output else str(e)
717
+ return False, str(err)[:300]
718
+
719
+
720
+ def log_post(thread_url, our_url, text, project_name, thread_author, thread_title,
721
+ github_username, engagement_style=None, search_topic=None, language=None,
722
+ claude_session_id=None, generation_trace_path=None, link_source=None):
723
+ """Defers to github_tools.py log-post, which handles dedup + INSERT.
724
+
725
+ Returns the new posts.id on success, or None on failure / dedup hit.
726
+ Callers who need attribution wiring (e.g. post_links backfill) check
727
+ the return for truthy before calling backfill_post_id.
728
+
729
+ generation_trace_path (added 2026-05-12): optional path to a JSON
730
+ file with the few-shot context Claude saw. Passed to github_tools.py
731
+ as --generation-trace and stored in posts.generation_trace JSONB.
732
+ File-based instead of inline-JSON to keep argv short (the report
733
+ text can be several KB) and to avoid shell-escape pain.
734
+
735
+ link_source (added 2026-05-17): tags audience-page traffic (e.g.
736
+ 'audience_page:founder-ghostwriting') so the dashboard can break out
737
+ curated landing-page hits from generic homepage links.
738
+ """
739
+ try:
740
+ cmd = [PYTHON, GITHUB_TOOLS, "log-post",
741
+ thread_url, our_url or "", text, project_name,
742
+ thread_author or "unknown", thread_title or "",
743
+ "--account", github_username]
744
+ if engagement_style:
745
+ cmd.extend(["--engagement-style", engagement_style])
746
+ if search_topic:
747
+ cmd.extend(["--search-topic", search_topic])
748
+ if language:
749
+ cmd.extend(["--language", language])
750
+ if claude_session_id:
751
+ cmd.extend(["--claude-session-id", claude_session_id])
752
+ if generation_trace_path:
753
+ cmd.extend(["--generation-trace", generation_trace_path])
754
+ if link_source:
755
+ cmd.extend(["--link-source", link_source])
756
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=15)
757
+ if result.stdout.strip():
758
+ try:
759
+ parsed = json.loads(result.stdout.strip())
760
+ if parsed.get("error"):
761
+ log(f"log-post error: {parsed}")
762
+ return None
763
+ # Success envelope from github_tools.py log-post should match
764
+ # log_post.py's shape: {"logged": true, "post_id": N, ...}.
765
+ pid = parsed.get("post_id")
766
+ return pid if isinstance(pid, int) else None
767
+ except json.JSONDecodeError:
768
+ pass
769
+ except Exception as e:
770
+ log(f"WARNING: log-post failed: {e}")
771
+ return None
772
+
773
+
774
+ # ---------- Main -------------------------------------------------------------
775
+
776
+ def main():
777
+ parser = argparse.ArgumentParser(
778
+ description="GitHub Issues posting orchestrator (momentum-gated)")
779
+ parser.add_argument("--sleep", type=int, default=600,
780
+ help="Phase 1 -> Phase 2 momentum window in seconds (default 600)")
781
+ parser.add_argument("--limit", type=int, default=None,
782
+ help="Hard ceiling on posts per run; caps the adaptive cap")
783
+ parser.add_argument("--timeout", type=int, default=900,
784
+ help="Claude drafting timeout in seconds")
785
+ parser.add_argument("--project", default=None, help="Override project selection")
786
+ parser.add_argument("--dry-run", action="store_true",
787
+ help="Print prompt + would-post candidates; do not invoke Claude or post")
788
+ args = parser.parse_args()
789
+
790
+ run_start = time.time()
791
+ # Arm the atexit/SIGTERM safety net: it skips emit until run_start is
792
+ # set, so any pre-main exit (argparse, etc.) is a no-op.
793
+ _RUN_STATE["run_start"] = run_start
794
+ log(f"=== GitHub run: sleep={args.sleep}s ===")
795
+
796
+ config = load_config()
797
+ github_username = config.get("accounts", {}).get("github", {}).get("username", "m13v")
798
+
799
+ # ---- Pick project ------------------------------------------------------
800
+ if args.project:
801
+ project = next(
802
+ (p for p in config.get("projects", [])
803
+ if p.get("name", "").lower() == args.project.lower()),
804
+ None,
805
+ )
806
+ if not project:
807
+ log(f"ERROR: project '{args.project}' not found")
808
+ sys.exit(1)
809
+ project_name = project.get("name")
810
+ log(f"Project (forced): {project_name}")
811
+ else:
812
+ # Shared inverse-recent-share picker (scripts/pick_project.py), the
813
+ # same selection logic twitter and reddit use.
814
+ picks = pick_project.pick_projects(config, platform="github", n=1)
815
+ project = picks[0] if picks else None
816
+ if project is None:
817
+ log("ERROR: no eligible project (none have search_topics)")
818
+ sys.exit(1)
819
+ project_name = project.get("name")
820
+ log(f"Project (inverse-recent-share): {project_name} "
821
+ f"(weight={project.get('weight', 0)})")
822
+
823
+ # ---- Phase 1: search topics, T0 snapshot -------------------------------
824
+ topics_pool = list(topics_for_project(project["name"]))
825
+ if not topics_pool:
826
+ log("Project has no topics to search. Exiting.")
827
+ sys.exit(0)
828
+ # Shuffle before slicing so each run samples a different MAX_TOPICS_PER_PROJECT
829
+ # subset. Without this, projects with >6 seeds always query the first 6, which
830
+ # starves diverse coverage and biases top_search_topics scoring (c0nsl run on
831
+ # 2026-04-24 yielded only 2 candidates because its first 6 seeds were narrow).
832
+ random.shuffle(topics_pool)
833
+ topics = topics_pool[:MAX_TOPICS_PER_PROJECT]
834
+
835
+ log(f"Phase 1: searching {len(topics)} topic queries...")
836
+ raw = []
837
+ seen_urls = set()
838
+ for topic in topics:
839
+ for item in gh_search(topic):
840
+ url = item.get("url")
841
+ if url and url not in seen_urls:
842
+ seen_urls.add(url)
843
+ # Stamp the originating seed so it survives dedup -> INSERT and
844
+ # feeds top_search_topics scoring on the next run.
845
+ item["search_topic"] = topic
846
+ raw.append(item)
847
+ log(f"Phase 1: {len(raw)} unique issues after dedup + already-posted filter")
848
+ if not raw:
849
+ log("No candidates. Exiting.")
850
+ sys.exit(0)
851
+
852
+ candidates = []
853
+ skipped_maintainer = 0
854
+ for item in raw[:CLAUDE_CANDIDATE_LIMIT * 3]:
855
+ repo, number = parse_repo_number(item.get("url"))
856
+ if not repo:
857
+ continue
858
+ counts = gh_view_counts(repo, number)
859
+ if not counts:
860
+ continue
861
+ # Maintainer-just-spoke gate. If the most recent comment is from someone
862
+ # with push access (OWNER/MEMBER/COLLABORATOR), they are driving the
863
+ # thread, so piling on reads as noise and risks LOW_QUALITY hide.
864
+ if counts.get("maintainer_last_speaker"):
865
+ skipped_maintainer += 1
866
+ log(
867
+ f" skip {repo}#{number}: maintainer-just-spoke "
868
+ f"(last={counts.get('last_commenter')}/"
869
+ f"{counts.get('last_comment_assoc')})"
870
+ )
871
+ continue
872
+ candidates.append({
873
+ "repo": repo,
874
+ "number": number,
875
+ "url": counts["url"],
876
+ "title": counts["title"],
877
+ "body": counts["body"],
878
+ "author": counts["author"],
879
+ "comment_count_t0": counts["comment_count"],
880
+ "reaction_count_t0": counts["reaction_count"],
881
+ "search_topic": item.get("search_topic"),
882
+ })
883
+ log(
884
+ f"Phase 1: {len(candidates)} candidates with T0 snapshot "
885
+ f"(skipped {skipped_maintainer} for maintainer-just-spoke)"
886
+ )
887
+ if not candidates:
888
+ log("No live open issues to re-poll. Exiting.")
889
+ sys.exit(0)
890
+
891
+ # ---- Sleep -------------------------------------------------------------
892
+ log(f"Sleeping {args.sleep}s before T1...")
893
+ time.sleep(args.sleep)
894
+
895
+ # ---- Phase 2a: re-poll T1 ---------------------------------------------
896
+ log("Phase 2a: re-polling T1 counts...")
897
+ survivors = []
898
+ skipped_maintainer_phase2 = 0
899
+ for c in candidates:
900
+ counts = gh_view_counts(c["repo"], c["number"])
901
+ if not counts:
902
+ c["comment_count_t1"] = c["comment_count_t0"]
903
+ c["reaction_count_t1"] = c["reaction_count_t0"]
904
+ c["delta_score"] = 0.0
905
+ survivors.append(c)
906
+ continue
907
+ # Re-check maintainer-just-spoke gate. A maintainer may have arrived
908
+ # during the sleep window. If so, drop to avoid piling on.
909
+ if counts.get("maintainer_last_speaker"):
910
+ skipped_maintainer_phase2 += 1
911
+ log(
912
+ f" phase2 skip {c['repo']}#{c['number']}: maintainer arrived "
913
+ f"during sleep (last={counts.get('last_commenter')}/"
914
+ f"{counts.get('last_comment_assoc')})"
915
+ )
916
+ continue
917
+ c["comment_count_t1"] = counts["comment_count"]
918
+ c["reaction_count_t1"] = counts["reaction_count"]
919
+ c["delta_score"] = delta_score(
920
+ c["comment_count_t0"], c["reaction_count_t0"],
921
+ c["comment_count_t1"], c["reaction_count_t1"],
922
+ )
923
+ survivors.append(c)
924
+ if skipped_maintainer_phase2:
925
+ log(
926
+ f"Phase 2a: dropped {skipped_maintainer_phase2} candidates "
927
+ f"after maintainer comment during sleep"
928
+ )
929
+ candidates = survivors
930
+ if not candidates:
931
+ log("Phase 2a: no candidates left after maintainer recheck. Exiting.")
932
+ sys.exit(0)
933
+
934
+ # ---- Phase 2b: adaptive cap -------------------------------------------
935
+ high_delta = [c for c in candidates if c["delta_score"] >= DELTA_THRESHOLD]
936
+ cap = CAP_BUMPED if len(high_delta) >= HIGH_DELTA_BUMP else CAP_DEFAULT
937
+ if args.limit is not None:
938
+ cap = min(cap, max(0, args.limit))
939
+ log(f"Phase 2b: {len(high_delta)} high-momentum candidates -> cap = {cap}")
940
+
941
+ candidates.sort(key=lambda c: c["delta_score"], reverse=True)
942
+ top = candidates[:CLAUDE_CANDIDATE_LIMIT]
943
+ log(f"Phase 2b: showing Claude top {len(top)} by delta, cap = {cap}")
944
+
945
+ if cap <= 0:
946
+ log("cap=0, nothing to post. Exiting.")
947
+ sys.exit(0)
948
+
949
+ top_report = get_top_performers(project_name)
950
+ recent_comments = get_recent_comments()
951
+ top_topics_report = get_top_search_topics(project_name, platform="github")
952
+
953
+ # 2026-05-22: pick the engagement style for this draft batch ONCE so
954
+ # validate_or_register can enforce the picker's choice on every post in
955
+ # the batch (USE mode coerces drift back; INVENT mode lets the model
956
+ # register a new style). GitHub batches share one assignment per cycle;
957
+ # cycles run frequently enough that the picker's distribution averages
958
+ # out across batches. Per-candidate assignment would require N picker
959
+ # calls + injected per-candidate blocks; deferred until the data shows
960
+ # it matters.
961
+ style_assignment = pick_style_for_post("github", context="posting")
962
+ log(f"Style assignment for this batch: mode={style_assignment.get('mode')} "
963
+ f"style={style_assignment.get('style') or '(invent)'}")
964
+
965
+ prompt = build_prompt(project, config, top, cap, top_report,
966
+ recent_comments, top_topics_report=top_topics_report,
967
+ style_assignment=style_assignment)
968
+
969
+ # Build the generation_trace audit blob: what Claude is about to see.
970
+ # Captured BEFORE the Claude call so we never end up with a post row
971
+ # missing its trace (e.g. if Claude errors out, we never call
972
+ # log_post and the file is GC'd by the OS). Same trace path is used
973
+ # for every post produced from this Claude invocation, since they
974
+ # all saw the same few-shot context.
975
+ #
976
+ # Why a temp file: argv has a ~256 KB cap on macOS and the top_report
977
+ # alone can run several KB. The path travels through 3 hops
978
+ # (post_github → log_post() → github_tools.py log-post) and stays
979
+ # cheap to pass; the JSON body only deserializes once at the SQL
980
+ # INSERT step. tempfile.NamedTemporaryFile(delete=False) so the file
981
+ # survives the with-block close and the child process can read it.
982
+ generation_trace_path = None
983
+ try:
984
+ trace = _gen_trace.build_trace(
985
+ platform="github",
986
+ project_name=project_name,
987
+ prompt_chars=len(prompt),
988
+ top_performers_text=top_report or "",
989
+ top_search_topics_text=top_topics_report or "",
990
+ # recent_comments here is the list of (id, content) tuples
991
+ # from get_recent_comments(); extract just the IDs.
992
+ recent_comment_ids=[pid for pid, _ in (recent_comments or [])],
993
+ model=None,
994
+ min_score_floor=5, # PLATFORM_MIN_SCORE['github']
995
+ )
996
+ generation_trace_path = _gen_trace.write_trace_tempfile(
997
+ trace, prefix="github_gen_trace_",
998
+ )
999
+ if generation_trace_path:
1000
+ log(f"Generation trace: {generation_trace_path} "
1001
+ f"({os.path.getsize(generation_trace_path)} bytes)")
1002
+ except Exception as e:
1003
+ # Audit row is nice-to-have, never a blocker. Log and continue.
1004
+ log(f"WARNING: generation_trace build failed ({e}); proceeding without trace")
1005
+
1006
+ if args.dry_run:
1007
+ log("=== DRY RUN ===")
1008
+ log(f"Prompt length: {len(prompt)} chars")
1009
+ if generation_trace_path:
1010
+ log(f"Trace would be saved with each post: {generation_trace_path}")
1011
+ for c in top[:cap]:
1012
+ log(f" would consider {c['repo']}#{c['number']} "
1013
+ f"delta={c['delta_score']:.1f} title={c['title'][:60]}")
1014
+ return
1015
+
1016
+ # ---- Phase 2b: invoke Claude (one-shot, no tools) ----------------------
1017
+ claude_session_id = str(uuid.uuid4())
1018
+ os.environ["CLAUDE_SESSION_ID"] = claude_session_id
1019
+ log("Phase 2b: invoking Claude for drafting...")
1020
+ claude_start = time.time()
1021
+ ok, output, usage = run_claude(prompt, timeout=args.timeout)
1022
+ log(f"Claude finished in {time.time() - claude_start:.0f}s (${usage['cost_usd']:.4f})")
1023
+ # Mirror cost into the safety-net state so a SIGTERM after this point
1024
+ # records the spend even if we never reach the post loop.
1025
+ _RUN_STATE["cost"] = usage["cost_usd"]
1026
+
1027
+ if not ok:
1028
+ log(f"Claude FAILED: {output[:300]}")
1029
+ _RUN_STATE["failed"] = 1
1030
+ subprocess.run([
1031
+ PYTHON, os.path.join(SCRIPTS, "log_run.py"),
1032
+ "--script", "post_github",
1033
+ "--posted", "0", "--skipped", "0", "--failed", "1",
1034
+ "--cost", f"{usage['cost_usd']:.4f}",
1035
+ "--elapsed", f"{int(time.time() - run_start)}",
1036
+ ])
1037
+ # Mark emitted so the atexit handler doesn't double-write the
1038
+ # tailored Claude-failure summary above.
1039
+ _RUN_STATE["emitted"] = True
1040
+ sys.exit(1)
1041
+
1042
+ decisions = parse_claude_json(output) or {}
1043
+ posts = decisions.get("posts", []) or []
1044
+ skipped = decisions.get("skipped", []) or []
1045
+ log(f"Claude picked {len(posts)}, skipped {len(skipped)}")
1046
+
1047
+ # Relevance gate. Anything Claude scored below MIN_RELEVANCE goes to the
1048
+ # skipped bucket, NOT posted, regardless of how confident the comment_text
1049
+ # reads. This is the programmatic backstop for the prompt rule.
1050
+ relevance_dropped = []
1051
+ kept_posts = []
1052
+ for d in posts:
1053
+ try:
1054
+ rel = int(d.get("relevance", 0))
1055
+ except (TypeError, ValueError):
1056
+ rel = 0
1057
+ if rel < MIN_RELEVANCE:
1058
+ relevance_dropped.append({
1059
+ "url": d.get("thread_url", ""),
1060
+ "reason": (
1061
+ f"low_relevance (relevance={rel}, "
1062
+ f"rationale={(d.get('relevance_rationale') or '').strip()[:120]})"
1063
+ ),
1064
+ })
1065
+ else:
1066
+ kept_posts.append(d)
1067
+ if relevance_dropped:
1068
+ log(
1069
+ f"Relevance gate dropped {len(relevance_dropped)}/{len(posts)} "
1070
+ f"draft(s) below MIN_RELEVANCE={MIN_RELEVANCE}"
1071
+ )
1072
+ for r in relevance_dropped:
1073
+ log(f" drop {r['url']}: {r['reason']}")
1074
+ skipped.extend(relevance_dropped)
1075
+ posts = kept_posts
1076
+
1077
+ if not posts:
1078
+ log("No valid post decisions. Last 500 chars of output:")
1079
+ log(output.strip()[-500:])
1080
+
1081
+ posted = 0
1082
+ failed = 0
1083
+ for i, decision in enumerate(posts):
1084
+ thread_url = decision.get("thread_url", "")
1085
+ text = (decision.get("comment_text") or "").strip()
1086
+ thread_author = decision.get("thread_author", "unknown")
1087
+ thread_title = decision.get("thread_title", "")
1088
+ # validate_or_register enforces the picker's batch-level assignment:
1089
+ # in USE mode any drifted engagement_style label is silently coerced
1090
+ # back to the assigned name; in INVENT mode the new_style block is
1091
+ # registered into engagement_styles_registry via the s4l API. All
1092
+ # posts in this batch share style_assignment by design (see picker
1093
+ # call above).
1094
+ engagement_style, _style_action = validate_or_register(
1095
+ decision,
1096
+ source_post={
1097
+ "platform": "github",
1098
+ "post_url": thread_url,
1099
+ "post_id": None,
1100
+ "model": decision.get("model"),
1101
+ },
1102
+ assigned_style=(style_assignment or {}).get("style"),
1103
+ assigned_mode=(style_assignment or {}).get("mode"),
1104
+ )
1105
+ language = (decision.get("language") or "en").strip().lower()[:5] or "en"
1106
+
1107
+ owner, repo, number = parse_issue_url(thread_url)
1108
+ if not owner or not text:
1109
+ log(f"SKIP: bad URL or empty text: {thread_url}")
1110
+ failed += 1
1111
+ _RUN_STATE["failed"] = failed
1112
+ continue
1113
+
1114
+ # URL-wrap before sending to GitHub. project for wrapping is the
1115
+ # decision-resolved match (e.g., the project whose repo the issue
1116
+ # belongs to) or the orchestrator's own project_name. log_post
1117
+ # uses the same fallback chain so attribution lines up.
1118
+ wrap_project = (decision.get("matched_project") or project_name or "").strip()
1119
+ minted_session = None
1120
+ # Audience-page detection (2026-05-17). Inspect the unwrapped text for
1121
+ # any URL that exactly matches a curated audience-page (e.g.
1122
+ # https://s4l.ai/ghostwriting). When found, posts.link_source is
1123
+ # stamped 'audience_page:<angle>' for the row. Detection runs BEFORE
1124
+ # wrap_text_for_post because wrapping rewrites the URLs to /r/<code>
1125
+ # short links; classify_url_as_audience_page() needs the original
1126
+ # target URL.
1127
+ audience_page_link_source = None
1128
+ if wrap_project:
1129
+ try:
1130
+ for _url_m in re.finditer(r'https?://[^\s)\]>"\']+', text):
1131
+ _raw = _url_m.group(0).rstrip('.,);!?]')
1132
+ _angle = _audience_classify_url(_raw, wrap_project)
1133
+ if _angle:
1134
+ audience_page_link_source = f"audience_page:{_angle}"
1135
+ break
1136
+ except Exception as _e:
1137
+ log(f"WARNING: audience-page classify raised ({_e})")
1138
+ if wrap_project:
1139
+ try:
1140
+ from dm_short_links import wrap_text_for_post, utm_only_text
1141
+ wrap_res = wrap_text_for_post(text=text, platform="github_issues",
1142
+ project_name=wrap_project)
1143
+ if wrap_res.get("ok"):
1144
+ text = wrap_res["text"]
1145
+ minted_session = wrap_res.get("minted_session")
1146
+ if wrap_res.get("codes"):
1147
+ log(f"wrapped {len(wrap_res['codes'])} URL(s): {wrap_res['codes']}")
1148
+ else:
1149
+ log(f"WARNING: URL wrap failed ({wrap_res.get('error')}); falling back to UTM-only")
1150
+ text = utm_only_text(text=text, platform="github_issues", project_name=wrap_project)
1151
+ except Exception as e:
1152
+ log(f"WARNING: URL wrap raised ({e}); falling back to UTM-only")
1153
+ try:
1154
+ from dm_short_links import utm_only_text
1155
+ text = utm_only_text(text=text, platform="github_issues", project_name=wrap_project)
1156
+ except Exception as ee:
1157
+ log(f"WARNING: UTM-only fallback also failed ({ee}); posting unwrapped")
1158
+
1159
+ log(f"Posting {i + 1}/{len(posts)} -> {owner}/{repo}#{number}: {thread_title[:60]}")
1160
+ ok_post, url_or_err = post_comment(owner, repo, number, text)
1161
+ if not ok_post:
1162
+ log(f"POST FAILED: {url_or_err}")
1163
+ failed += 1
1164
+ _RUN_STATE["failed"] = failed
1165
+ time.sleep(3)
1166
+ continue
1167
+
1168
+ new_post_id = log_post(
1169
+ thread_url, url_or_err, text,
1170
+ decision.get("matched_project") or project_name,
1171
+ thread_author, thread_title, github_username,
1172
+ engagement_style=engagement_style,
1173
+ search_topic=(decision.get("search_topic") or "").strip() or None,
1174
+ language=language,
1175
+ claude_session_id=claude_session_id,
1176
+ # Same trace blob for every post in this run — they all saw
1177
+ # the same few-shot context. If the trace file couldn't be
1178
+ # built earlier this is None and log_post drops the flag.
1179
+ generation_trace_path=generation_trace_path,
1180
+ link_source=audience_page_link_source,
1181
+ )
1182
+ # Stamp post_links.post_id for the URLs minted before posting.
1183
+ # Idempotent; no-op when minted_session is None or the dedup path
1184
+ # in github_tools.py log-post returned no post_id (e.g., dup thread).
1185
+ if minted_session and new_post_id:
1186
+ try:
1187
+ from dm_short_links import backfill_post_id
1188
+ backfill_post_id(minted_session=minted_session, post_id=new_post_id)
1189
+ except Exception as e:
1190
+ log(f"WARNING: backfill_post_id failed ({e})")
1191
+ posted += 1
1192
+ # Keep the safety-net counters in sync after each successful post so
1193
+ # a SIGTERM mid-loop still emits the partial-but-correct count.
1194
+ _RUN_STATE["posted"] = posted
1195
+ _RUN_STATE["failed"] = failed
1196
+ _RUN_STATE["skipped"] = len(skipped)
1197
+ log(f"POSTED: {url_or_err or 'ok'}")
1198
+
1199
+ # ---- Optional self-reply with ONE github blob URL ------------------
1200
+ # Restored 2026-05-17 after the April 13 over-correction stripped
1201
+ # github of all CTAs (zero link_edit_content rows in May). The bundle
1202
+ # is the proven pattern for driving clicks; the model gets explicit
1203
+ # license to skip it (self_reply_text=null) when it can't point to a
1204
+ # genuinely relevant file in our repos. See the "Self-reply policy"
1205
+ # section of build_prompt() for the three-condition skip rule.
1206
+ sr_raw = (decision.get("self_reply_text") or "")
1207
+ sr_text = sr_raw.strip() if isinstance(sr_raw, str) else ""
1208
+ if sr_text and re.search(r"https?://github\.com/", sr_text):
1209
+ # 30-90s jitter between parent and child. The March 18 strike pair
1210
+ # (3072/3073) posted 0.2s apart, which is the bot-signature
1211
+ # timing. Humans don't follow up that fast. Random within the
1212
+ # window so we don't accumulate a uniform-timing fingerprint.
1213
+ sr_delay = random.randint(30, 90)
1214
+ log(f"Self-reply queued after {sr_delay}s delay...")
1215
+ time.sleep(sr_delay)
1216
+
1217
+ # URL-wrap the self-reply via dm_short_links so the github blob
1218
+ # link gets /r/<code> or UTM-tagged attribution, same as every
1219
+ # other URL in the pipeline. CLAUDE.md: "Never post bare URLs."
1220
+ sr_minted_session = None
1221
+ if wrap_project:
1222
+ try:
1223
+ from dm_short_links import wrap_text_for_post, utm_only_text
1224
+ sr_wrap = wrap_text_for_post(text=sr_text, platform="github_issues",
1225
+ project_name=wrap_project)
1226
+ if sr_wrap.get("ok"):
1227
+ sr_text = sr_wrap["text"]
1228
+ sr_minted_session = sr_wrap.get("minted_session")
1229
+ if sr_wrap.get("codes"):
1230
+ log(f"self-reply wrapped {len(sr_wrap['codes'])} URL(s): "
1231
+ f"{sr_wrap['codes']}")
1232
+ else:
1233
+ log(f"WARNING: self-reply URL wrap failed "
1234
+ f"({sr_wrap.get('error')}); falling back to UTM-only")
1235
+ sr_text = utm_only_text(text=sr_text, platform="github_issues",
1236
+ project_name=wrap_project)
1237
+ except Exception as e:
1238
+ log(f"WARNING: self-reply URL wrap raised ({e}); "
1239
+ f"falling back to UTM-only")
1240
+ try:
1241
+ from dm_short_links import utm_only_text
1242
+ sr_text = utm_only_text(text=sr_text, platform="github_issues",
1243
+ project_name=wrap_project)
1244
+ except Exception as ee:
1245
+ log(f"WARNING: self-reply UTM-only fallback also failed ({ee}); "
1246
+ f"posting unwrapped")
1247
+
1248
+ ok_sr, sr_url_or_err = post_comment(owner, repo, number, sr_text)
1249
+ if ok_sr:
1250
+ sr_post_id = log_post(
1251
+ thread_url, sr_url_or_err, sr_text,
1252
+ decision.get("matched_project") or project_name,
1253
+ thread_author, thread_title, github_username,
1254
+ engagement_style=engagement_style,
1255
+ search_topic=(decision.get("search_topic") or "").strip() or None,
1256
+ language=language,
1257
+ claude_session_id=claude_session_id,
1258
+ generation_trace_path=generation_trace_path,
1259
+ link_source=audience_page_link_source,
1260
+ )
1261
+ if sr_minted_session and sr_post_id:
1262
+ try:
1263
+ from dm_short_links import backfill_post_id
1264
+ backfill_post_id(minted_session=sr_minted_session,
1265
+ post_id=sr_post_id)
1266
+ except Exception as e:
1267
+ log(f"WARNING: self-reply backfill_post_id failed ({e})")
1268
+ posted += 1
1269
+ _RUN_STATE["posted"] = posted
1270
+ log(f"SELF-REPLY POSTED: {sr_url_or_err or 'ok'}")
1271
+ else:
1272
+ log(f"SELF-REPLY FAILED: {sr_url_or_err}")
1273
+ failed += 1
1274
+ _RUN_STATE["failed"] = failed
1275
+
1276
+ time.sleep(3)
1277
+
1278
+ # Clean up the generation_trace temp file. By this point every post
1279
+ # that landed has its trace persisted to posts.generation_trace JSONB,
1280
+ # so the on-disk JSON is redundant. macOS would eventually purge
1281
+ # /var/folders/, but explicit cleanup keeps temp dirs tidy when this
1282
+ # runs every 20 min via launchd.
1283
+ _gen_trace.cleanup_trace_tempfile(generation_trace_path)
1284
+
1285
+ total_elapsed = time.time() - run_start
1286
+ log(f"=== SUMMARY: elapsed={total_elapsed:.0f}s posted={posted} failed={failed} ===")
1287
+ log(f"Tokens: input={usage['input_tokens']} output={usage['output_tokens']} "
1288
+ f"cache_read={usage['cache_read']} cache_create={usage['cache_create']}")
1289
+ log(f"Cost: ${usage['cost_usd']:.4f}")
1290
+
1291
+ # Final happy-path summary write. Sync the safety-net state in case the
1292
+ # last post-loop iteration didn't (e.g. zero candidates kept), then mark
1293
+ # emitted so the atexit handler short-circuits.
1294
+ _RUN_STATE["posted"] = posted
1295
+ _RUN_STATE["failed"] = failed
1296
+ _RUN_STATE["skipped"] = len(skipped)
1297
+ _RUN_STATE["cost"] = usage["cost_usd"]
1298
+ subprocess.run([
1299
+ PYTHON, os.path.join(SCRIPTS, "log_run.py"),
1300
+ "--script", "post_github",
1301
+ "--posted", str(posted),
1302
+ "--skipped", str(len(skipped)),
1303
+ "--failed", str(failed),
1304
+ "--cost", f"{usage['cost_usd']:.4f}",
1305
+ "--elapsed", f"{int(total_elapsed)}",
1306
+ ])
1307
+ _RUN_STATE["emitted"] = True
1308
+
1309
+
1310
+ if __name__ == "__main__":
1311
+ main()