@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,509 @@
1
+ #!/usr/bin/env python3
2
+ """GitHub CLI tools for Claude to call via Bash.
3
+
4
+ Commands:
5
+ python3 scripts/github_tools.py search "QUERY" [--limit 10]
6
+ python3 scripts/github_tools.py view OWNER/REPO NUMBER
7
+ python3 scripts/github_tools.py already-posted "THREAD_URL"
8
+ python3 scripts/github_tools.py log-post THREAD_URL OUR_URL OUR_TEXT PROJECT THREAD_AUTHOR THREAD_TITLE [--account m13v] [--engagement-style STYLE]
9
+ """
10
+
11
+ import argparse
12
+ import json
13
+ import os
14
+ import subprocess
15
+ import sys
16
+ import time
17
+
18
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
19
+ from http_api import api_get, api_post
20
+ from version import read_version as read_autoposter_version
21
+ try:
22
+ from account_resolver import resolve as _resolve_account
23
+ except Exception:
24
+ def _resolve_account(_platform): # type: ignore[unused-arg]
25
+ return None
26
+
27
+
28
+ def _github_account_filter():
29
+ """Return (sql_fragment, params) for a github our_account scope.
30
+
31
+ Empty tuple of params means no scoping is applied (legacy behavior).
32
+ Used so the same query shape works with and without a configured handle.
33
+ """
34
+ h = _resolve_account("github")
35
+ if h:
36
+ return (" AND our_account = %s", [h])
37
+ return ("", [])
38
+
39
+ CONFIG_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "config.json")
40
+
41
+
42
+ def _load_config():
43
+ with open(CONFIG_PATH) as f:
44
+ return json.load(f)
45
+
46
+
47
+ def _excluded_repos_and_authors(config):
48
+ exclusions = config.get("exclusions", {})
49
+ repos = {r.lower() for r in exclusions.get("github_repos", [])}
50
+ authors = {a.lower() for a in exclusions.get("authors", [])}
51
+ return repos, authors
52
+
53
+
54
+ # Auto-blocklist: any owner where >= DYNAMIC_BLOCK_THRESHOLD of our github
55
+ # posts under that owner have been moderated (status='deleted' OR
56
+ # deletion_detect_count > 0) within the last DYNAMIC_BLOCK_WINDOW_DAYS days.
57
+ # One strike = stop posting under that owner. The cost of one extra burned
58
+ # comment is much higher than the cost of skipping a borderline-friendly
59
+ # repo. Tuned 2026-05-01 after the antiwork/gumroad block: deletion of #4677
60
+ # alone should have stopped us before #4915. Tightened 2->1 on 2026-06-04
61
+ # after rausermack22-dotcom content-farm repo burned 2 mk0r comments before
62
+ # the owner hit the threshold.
63
+ DYNAMIC_BLOCK_THRESHOLD = 1
64
+ DYNAMIC_BLOCK_WINDOW_DAYS = 90
65
+
66
+
67
+ _REPO_GONE_CACHE_PATH = os.path.expanduser(
68
+ "~/social-autoposter/skill/cache/github_repo_state.json"
69
+ )
70
+ _REPO_GONE_TTL_SEC = 24 * 3600 # 24h is plenty: a deleted repo stays deleted
71
+
72
+
73
+ def _load_repo_gone_cache():
74
+ try:
75
+ with open(_REPO_GONE_CACHE_PATH) as f:
76
+ return json.load(f)
77
+ except Exception:
78
+ return {}
79
+
80
+
81
+ def _save_repo_gone_cache(cache):
82
+ try:
83
+ os.makedirs(os.path.dirname(_REPO_GONE_CACHE_PATH), exist_ok=True)
84
+ with open(_REPO_GONE_CACHE_PATH, "w") as f:
85
+ json.dump(cache, f)
86
+ except Exception:
87
+ pass
88
+
89
+
90
+ def _fetch_repo_state(owner, repo, _mem={}, _disk={"loaded": False, "data": {}}):
91
+ """Fetch and cache (gone, has_issues, has_discussions) for owner/repo.
92
+ Two-tier cache (in-process + 24h on-disk JSON). Returns a dict
93
+ {gone: bool, has_issues: bool, has_discussions: bool}."""
94
+ key = f"{owner}/{repo}".lower()
95
+ if key in _mem:
96
+ return _mem[key]
97
+ if not _disk["loaded"]:
98
+ _disk["data"] = _load_repo_gone_cache()
99
+ _disk["loaded"] = True
100
+ entry = _disk["data"].get(key)
101
+ now = int(time.time())
102
+ if (entry and (now - int(entry.get("checked_at", 0))) < _REPO_GONE_TTL_SEC
103
+ and "has_issues" in entry):
104
+ state = {
105
+ "gone": bool(entry.get("gone")),
106
+ "has_issues": bool(entry.get("has_issues", True)),
107
+ "has_discussions": bool(entry.get("has_discussions", True)),
108
+ }
109
+ _mem[key] = state
110
+ return state
111
+ try:
112
+ proc = subprocess.run(
113
+ ["gh", "api", f"repos/{owner}/{repo}"],
114
+ capture_output=True, text=True, timeout=20,
115
+ )
116
+ except Exception:
117
+ state = {"gone": False, "has_issues": True, "has_discussions": True}
118
+ _mem[key] = state
119
+ return state
120
+ if proc.returncode == 0:
121
+ try:
122
+ data = json.loads(proc.stdout or "{}")
123
+ except Exception:
124
+ data = {}
125
+ state = {
126
+ "gone": False,
127
+ "has_issues": bool(data.get("has_issues", True)),
128
+ "has_discussions": bool(data.get("has_discussions", True)),
129
+ }
130
+ else:
131
+ err = ((proc.stderr or "") + (proc.stdout or "")).lower()
132
+ gone = ("not found" in err or "http 404" in err)
133
+ state = {"gone": gone, "has_issues": True, "has_discussions": True}
134
+ _mem[key] = state
135
+ _disk["data"][key] = {
136
+ "gone": state["gone"],
137
+ "has_issues": state["has_issues"],
138
+ "has_discussions": state["has_discussions"],
139
+ "checked_at": now,
140
+ }
141
+ _save_repo_gone_cache(_disk["data"])
142
+ return state
143
+
144
+
145
+ def _repo_is_gone(owner, repo):
146
+ """Back-compat alias. Returns True iff the parent repo 404s. Callers
147
+ that want the broader 'this URL is unreachable for non-moderation
148
+ reasons' should use _post_is_collateral(thread_url) instead."""
149
+ return _fetch_repo_state(owner, repo)["gone"]
150
+
151
+
152
+ def _post_is_collateral(thread_url):
153
+ """Returns True iff this thread_url died for a non-moderation reason:
154
+ the whole repo 404'd, OR the repo is alive but the feature this URL
155
+ lived on (issues, discussions) has been disabled by the owner. Both
156
+ cases mean every comment under that URL vanished at once and ours is
157
+ not a targeted strike."""
158
+ if not thread_url:
159
+ return False
160
+ from urllib.parse import urlparse as _urlparse
161
+ parts = _urlparse(thread_url).path.strip("/").split("/")
162
+ if len(parts) < 2 or not parts[0] or not parts[1]:
163
+ return False
164
+ owner, repo = parts[0], parts[1]
165
+ state = _fetch_repo_state(owner, repo)
166
+ if state["gone"]:
167
+ return True
168
+ if len(parts) >= 3:
169
+ if parts[2] == "issues" and not state["has_issues"]:
170
+ return True
171
+ if parts[2] == "discussions" and not state["has_discussions"]:
172
+ return True
173
+ return False
174
+
175
+
176
+ def _dynamic_owner_blocklist(threshold=DYNAMIC_BLOCK_THRESHOLD,
177
+ days=DYNAMIC_BLOCK_WINDOW_DAYS):
178
+ """Return lowercased owner names with >=threshold moderated posts in the
179
+ last `days` days. Posts whose entire parent repo is 404 OR whose host
180
+ feature (Issues/Discussions) has been turned off on the repo are excluded
181
+ from the count: owner restructured the project, not a hostility signal.
182
+ Caller unions with static config exclusions before filtering candidates."""
183
+ # Dynamic owner blocklist is scoped per-account so the @matt_diak
184
+ # autoposter doesn't inherit @m13v_'s strike history (or vice versa).
185
+ # Falls back to unscoped when no handle is configured. The moderation
186
+ # filter (status='deleted' OR deletion_detect_count>0, inside the window)
187
+ # is applied server-side via moderated_within_days; the owner-counting and
188
+ # collateral exclusion stay local.
189
+ query = {"platform": "github", "moderated_within_days": str(int(days))}
190
+ handle = _resolve_account("github")
191
+ if handle:
192
+ query["our_account"] = handle
193
+ try:
194
+ resp = api_get("/api/v1/posts/thread-urls", query=query)
195
+ rows = ((resp or {}).get("data") or {}).get("thread_urls") or []
196
+ except Exception:
197
+ return set()
198
+ from collections import Counter
199
+ from urllib.parse import urlparse
200
+ counts = Counter()
201
+ for url in rows:
202
+ if not url:
203
+ continue
204
+ parts = urlparse(url).path.strip("/").split("/")
205
+ if len(parts) < 2 or not parts[0] or not parts[1]:
206
+ continue
207
+ if _post_is_collateral(url):
208
+ # Repo gone or feature disabled: drop from strike count.
209
+ continue
210
+ counts[parts[0].lower()] += 1
211
+ blocked = {owner for owner, n in counts.items() if n >= threshold}
212
+ if blocked:
213
+ print(
214
+ f"[github_blocklist] threshold={threshold} window_days={days} "
215
+ f"blocked={sorted(blocked)}",
216
+ file=sys.stderr,
217
+ )
218
+ return blocked
219
+
220
+
221
+ def _is_excluded_repo(repo_full, excluded_repos):
222
+ """repo_full is 'owner/name'. Match if either owner or name or full is in excluded list."""
223
+ if not repo_full:
224
+ return False
225
+ rl = repo_full.lower()
226
+ owner = rl.split("/", 1)[0] if "/" in rl else rl
227
+ name = rl.split("/", 1)[1] if "/" in rl else rl
228
+ return rl in excluded_repos or owner in excluded_repos or name in excluded_repos
229
+
230
+
231
+ def cmd_search(args):
232
+ """Search GitHub for issues via gh CLI. Filters out excluded repos/authors and already-posted threads."""
233
+ try:
234
+ out = subprocess.check_output(
235
+ ["gh", "search", "issues", args.query,
236
+ "--limit", str(args.limit),
237
+ "--state", "open",
238
+ "--sort", "updated",
239
+ "--json", "number,title,repository,author,state,updatedAt,url,body"],
240
+ text=True, timeout=30, stderr=subprocess.STDOUT,
241
+ )
242
+ items = json.loads(out)
243
+ except subprocess.CalledProcessError as e:
244
+ print(json.dumps({"error": "gh_search_failed", "message": (e.output or str(e))[:300]}))
245
+ sys.exit(2)
246
+ except (subprocess.TimeoutExpired, json.JSONDecodeError) as e:
247
+ print(json.dumps({"error": "gh_search_failed", "message": str(e)[:300]}))
248
+ sys.exit(2)
249
+
250
+ config = _load_config()
251
+ excluded_repos, excluded_authors = _excluded_repos_and_authors(config)
252
+
253
+ excluded_repos = excluded_repos | _dynamic_owner_blocklist()
254
+ # Per-account dedupe: only filter against threads THIS handle posted in.
255
+ _tu_query = {"platform": "github"}
256
+ _handle = _resolve_account("github")
257
+ if _handle:
258
+ _tu_query["our_account"] = _handle
259
+ _tu_resp = api_get("/api/v1/posts/thread-urls", query=_tu_query)
260
+ already_posted = set(((_tu_resp or {}).get("data") or {}).get("thread_urls") or [])
261
+
262
+ results = []
263
+ for item in items:
264
+ repo = item.get("repository", {}) or {}
265
+ repo_full = repo.get("nameWithOwner") or (
266
+ f"{repo.get('owner', {}).get('login', '')}/{repo.get('name', '')}"
267
+ if repo.get("owner") else ""
268
+ )
269
+ author = (item.get("author") or {}).get("login", "")
270
+
271
+ if _is_excluded_repo(repo_full, excluded_repos):
272
+ continue
273
+ if author.lower() in excluded_authors:
274
+ continue
275
+
276
+ url = item.get("url", "")
277
+ already = url in already_posted
278
+ entry = {
279
+ "url": url,
280
+ "title": item.get("title", ""),
281
+ "author": author,
282
+ "repo": repo_full,
283
+ "number": item.get("number"),
284
+ "updated_at": item.get("updatedAt", ""),
285
+ "body_preview": (item.get("body") or ""),
286
+ "already_posted": already,
287
+ }
288
+ if already:
289
+ entry["SKIP"] = ">>> ALREADY POSTED IN THIS THREAD - DO NOT POST AGAIN <<<"
290
+ results.append(entry)
291
+
292
+ print(json.dumps(results, indent=2))
293
+
294
+
295
+ def cmd_view(args):
296
+ """Fetch issue body and comments via gh CLI. Returns compact JSON."""
297
+ # args.repo is 'owner/repo', args.number is the issue number
298
+ try:
299
+ out = subprocess.check_output(
300
+ ["gh", "issue", "view", str(args.number), "-R", args.repo,
301
+ "--json", "title,body,author,state,comments,url"],
302
+ text=True, timeout=30, stderr=subprocess.STDOUT,
303
+ )
304
+ thread = json.loads(out)
305
+ except subprocess.CalledProcessError as e:
306
+ print(json.dumps({"error": "gh_view_failed", "message": (e.output or str(e))[:300]}))
307
+ return
308
+ except (subprocess.TimeoutExpired, json.JSONDecodeError) as e:
309
+ print(json.dumps({"error": "gh_view_failed", "message": str(e)[:300]}))
310
+ return
311
+
312
+ comments = []
313
+ for c in (thread.get("comments") or []):
314
+ comments.append({
315
+ "author": (c.get("author") or {}).get("login", ""),
316
+ "body": (c.get("body") or ""),
317
+ })
318
+
319
+ compact = {
320
+ "url": thread.get("url", ""),
321
+ "title": thread.get("title", ""),
322
+ "state": thread.get("state", ""),
323
+ "author": (thread.get("author") or {}).get("login", ""),
324
+ "body": (thread.get("body") or ""),
325
+ "comments": comments,
326
+ }
327
+
328
+ text = json.dumps(compact, indent=2)
329
+ print(text)
330
+
331
+
332
+ def cmd_already_posted(args):
333
+ """Check if we already posted in a GitHub issue thread.
334
+
335
+ Scoped per-account so multi-machine setups don't false-positive on
336
+ each other's posts. Falls back to unscoped when no handle is configured.
337
+ """
338
+ query = {"platform": "github", "thread_url": args.url}
339
+ handle = _resolve_account("github")
340
+ if handle:
341
+ query["our_account"] = handle
342
+ resp = api_get("/api/v1/posts/lookup", query=query)
343
+ row = ((resp or {}).get("data") or {}).get("post")
344
+ if row:
345
+ print(json.dumps({"already_posted": True, "post_id": row.get("id"),
346
+ "content_preview": row.get("our_content")}))
347
+ else:
348
+ print(json.dumps({"already_posted": False}))
349
+
350
+
351
+ def cmd_log_post(args):
352
+ """Log a posted GitHub comment to the database.
353
+
354
+ Enforces two dedup rules:
355
+ 1. Same comment URL is never logged twice (our_url hard dedup).
356
+ 2. Only one post per GitHub issue thread (thread_url hard dedup).
357
+ """
358
+ # our_url stays globally unique (it's a permalink to a specific comment,
359
+ # and two accounts can't physically produce the same one). thread_url
360
+ # dedup is scoped per-account so two handles can each comment once in
361
+ # the same upstream issue thread.
362
+ handle = _resolve_account("github")
363
+ if args.our_url:
364
+ resp = api_get("/api/v1/posts/lookup",
365
+ query={"platform": "github", "our_url": args.our_url})
366
+ existing = ((resp or {}).get("data") or {}).get("post")
367
+ if existing:
368
+ print(json.dumps({"error": "DUPLICATE_URL", "message": "Already logged this comment URL", "existing_post_id": existing.get("id")}))
369
+ return
370
+
371
+ _tq = {"platform": "github", "thread_url": args.thread_url}
372
+ if handle:
373
+ _tq["our_account"] = handle
374
+ resp = api_get("/api/v1/posts/lookup", query=_tq)
375
+ existing = ((resp or {}).get("data") or {}).get("post")
376
+ if existing:
377
+ print(json.dumps({
378
+ "error": "DUPLICATE_THREAD",
379
+ "message": "Already posted in this thread",
380
+ "existing_post_id": existing.get("id"),
381
+ "content_preview": existing.get("our_content"),
382
+ }))
383
+ return
384
+
385
+ # claude_session_id may come either via --claude-session-id or via the
386
+ # CLAUDE_SESSION_ID env var (set by run_claude.sh). CLI arg wins.
387
+ session_id = (getattr(args, "claude_session_id", None)
388
+ or os.environ.get("CLAUDE_SESSION_ID")
389
+ or None)
390
+ # Generation trace: opaque JSON blob captured by the generator before
391
+ # invoking Claude. Loaded from a file path (--generation-trace) because
392
+ # the JSON can be several KB and passing it inline via argv blows past
393
+ # macOS ARG_MAX. Passed to the API as a parsed object (the POST route
394
+ # serializes + caps at 1 MB). Failure to read just nulls the column —
395
+ # never blocks the post, since losing the audit row for one post is
396
+ # preferable to losing the post.
397
+ generation_trace_obj = None
398
+ trace_path = getattr(args, "generation_trace", None)
399
+ if trace_path:
400
+ try:
401
+ with open(trace_path, "r", encoding="utf-8") as tf:
402
+ generation_trace_obj = json.load(tf)
403
+ except (OSError, json.JSONDecodeError) as e:
404
+ # Stderr only — stdout is reserved for the JSON envelope
405
+ # that post_github.py:log_post() parses.
406
+ print(f"WARNING: could not load generation_trace {trace_path}: {e}",
407
+ file=sys.stderr)
408
+
409
+ payload = {
410
+ "platform": "github",
411
+ "thread_url": args.thread_url,
412
+ "thread_author": args.thread_author,
413
+ "thread_title": args.thread_title,
414
+ "thread_content": "",
415
+ "our_url": args.our_url,
416
+ "our_content": args.our_text,
417
+ "our_account": args.account,
418
+ "source_summary": "",
419
+ "project": args.project,
420
+ "engagement_style": getattr(args, "engagement_style", None),
421
+ "search_topic": getattr(args, "search_topic", None),
422
+ "language": (getattr(args, "language", None) or "en"),
423
+ "claude_session_id": session_id,
424
+ "link_source": getattr(args, "link_source", None),
425
+ "autoposter_version": read_autoposter_version(),
426
+ }
427
+ if generation_trace_obj is not None:
428
+ payload["generation_trace"] = generation_trace_obj
429
+ resp = api_post("/api/v1/posts", payload, ok_on_conflict=True)
430
+ if not (resp or {}).get("ok"):
431
+ # Backstop: the POST route dedups (platform, thread_url) globally and
432
+ # 409s. Our per-account pre-check above already caught the common case;
433
+ # a 409 here is a cross-account thread collision. Surface DUPLICATE_THREAD.
434
+ e = (resp or {}).get("error") or {}
435
+ print(json.dumps({
436
+ "error": "DUPLICATE_THREAD",
437
+ "message": e.get("message") or "already posted in this thread",
438
+ "existing_post_id": (resp or {}).get("existing_post_id") or e.get("existing_post_id"),
439
+ }))
440
+ return
441
+ new_id = (((resp or {}).get("data") or {}).get("post") or {}).get("id")
442
+ # post_id surfaced so post_github.py:log_post can backfill post_links
443
+ # for click attribution. Shape mirrors log_post.py's INSERT envelope.
444
+ print(json.dumps({"logged": True, "post_id": new_id}))
445
+
446
+
447
+ def main():
448
+ parser = argparse.ArgumentParser(description="GitHub tools for Claude")
449
+ sub = parser.add_subparsers(dest="command")
450
+
451
+ # search
452
+ p_search = sub.add_parser("search", help="Search GitHub issues")
453
+ p_search.add_argument("query")
454
+ p_search.add_argument("--limit", type=int, default=10)
455
+
456
+ # view
457
+ p_view = sub.add_parser("view", help="Fetch issue body + comments")
458
+ p_view.add_argument("repo", help="owner/repo")
459
+ p_view.add_argument("number", help="Issue number")
460
+
461
+ # already-posted
462
+ p_ap = sub.add_parser("already-posted", help="Check if we posted in this thread")
463
+ p_ap.add_argument("url")
464
+
465
+ # log-post
466
+ p_log = sub.add_parser("log-post", help="Log a posted comment to DB")
467
+ p_log.add_argument("thread_url")
468
+ p_log.add_argument("our_url")
469
+ p_log.add_argument("our_text")
470
+ p_log.add_argument("project")
471
+ p_log.add_argument("thread_author")
472
+ p_log.add_argument("thread_title")
473
+ p_log.add_argument("--account", default="m13v")
474
+ p_log.add_argument("--engagement-style", dest="engagement_style", default=None)
475
+ p_log.add_argument("--search-topic", dest="search_topic", default=None,
476
+ help="The seed topic/query used to find this issue (feedback loop input)")
477
+ p_log.add_argument("--language", dest="language", default=None,
478
+ help="ISO 639-1 language code of the issue (defaults to en if omitted)")
479
+ p_log.add_argument("--claude-session-id", dest="claude_session_id", default=None,
480
+ help="UUID of the Claude session that drafted this post (falls back to CLAUDE_SESSION_ID env var)")
481
+ p_log.add_argument("--generation-trace", dest="generation_trace", default=None,
482
+ help="Path to a JSON file with the few-shot context Claude "
483
+ "saw before drafting (top_performers report, recent "
484
+ "comments, top_search_topics, model, prompt size). "
485
+ "Stored in posts.generation_trace JSONB for audit. "
486
+ "See migrations/2026-05-12_generation_trace.sql for "
487
+ "the shape contract.")
488
+ p_log.add_argument("--link-source", dest="link_source", default=None,
489
+ help="Optional tag for posts.link_source so the dashboard "
490
+ "can break out audience-page traffic (e.g. "
491
+ "'audience_page:founder-ghostwriting') from generic "
492
+ "homepage links.")
493
+
494
+ args = parser.parse_args()
495
+ if args.command == "search":
496
+ cmd_search(args)
497
+ elif args.command == "view":
498
+ cmd_view(args)
499
+ elif args.command == "already-posted":
500
+ cmd_already_posted(args)
501
+ elif args.command == "log-post":
502
+ cmd_log_post(args)
503
+ else:
504
+ parser.print_help()
505
+ sys.exit(1)
506
+
507
+
508
+ if __name__ == "__main__":
509
+ main()