@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,640 @@
1
+ #!/usr/bin/env python3
2
+ """GitHub issues reply engagement orchestrator.
3
+
4
+ Processes pending GitHub issue replies one at a time, each in its own Claude session.
5
+ Before deciding, fetches the full issue thread via gh CLI so Claude can see the
6
+ entire conversation (title, body, every comment, our own prior replies) and make
7
+ a thread-aware reply-or-skip decision with a JSON escape hatch.
8
+
9
+ This replaces the batched inline prompt in skill/github-engage.sh, which fed
10
+ truncated snippets to Claude with a "Process EVERY reply" directive. That design
11
+ produced spammy self-promotion comments that got flagged on fastrepl/char#4881.
12
+
13
+ Usage:
14
+ python3 scripts/engage_github.py
15
+ python3 scripts/engage_github.py --dry-run # Print prompt for first reply, don't post
16
+ python3 scripts/engage_github.py --limit 5 # Process at most 5 replies
17
+ python3 scripts/engage_github.py --timeout 3600 # Global timeout in seconds
18
+ """
19
+
20
+ import argparse
21
+ import json
22
+ import os
23
+ import re
24
+ import subprocess
25
+ import sys
26
+ import time
27
+ import uuid
28
+ from datetime import datetime, timezone
29
+
30
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
31
+ from http_api import api_get
32
+ from engagement_styles import get_styles_prompt, get_anti_patterns, get_voice_relationship_rule
33
+
34
+ REPO_DIR = os.path.expanduser("~/social-autoposter")
35
+ CONFIG_PATH = os.path.join(REPO_DIR, "config.json")
36
+ REPLY_DB = os.path.join(REPO_DIR, "scripts", "reply_db.py")
37
+ SKILL_FILE = os.path.join(REPO_DIR, "SKILL.md")
38
+
39
+ # Interpreter every child subprocess must run under. A bare PYTHON resolved
40
+ # to the user's system python, which lacks the pipeline deps that live only in
41
+ # the owned uv runtime — the same fresh-box failure class that broke the Twitter
42
+ # poster (Karol, 2026-06-22). The GitHub rail posts via the REST API (no browser,
43
+ # so no Playwright dep), but its util/DB children still need the owned venv, so
44
+ # pin the interpreter here too. Honor S4L_PYTHON (set by the launchd plist),
45
+ # else sys.executable; never the literal PYTHON.
46
+ PYTHON = os.environ.get("S4L_PYTHON") or sys.executable
47
+ os.environ["S4L_PYTHON"] = PYTHON
48
+
49
+ # Cap the thread JSON we pass to Claude. Long issues with 100+ comments would
50
+ # otherwise blow the prompt budget. 12k chars is ~3k tokens, enough for most
51
+ # threads while leaving headroom for the rules and output.
52
+ THREAD_CHAR_CAP = 12000
53
+
54
+
55
+ def load_config():
56
+ with open(CONFIG_PATH) as f:
57
+ return json.load(f)
58
+
59
+
60
+ def get_next_pending():
61
+ """Fetch the next pending GitHub reply (one at a time, oldest first).
62
+
63
+ Routes through /api/v1/replies/next-pending, which LEFT-JOINs posts +
64
+ mentions server-side and filters orphans. GitHub replies are always
65
+ post-rooted, so the post-side fields (thread_title/thread_url/our_content/
66
+ our_url) come back populated exactly as the old INNER JOIN produced.
67
+ """
68
+ resp = api_get("/api/v1/replies/next-pending",
69
+ query={"platform": "github", "limit": 1})
70
+ rows = ((resp or {}).get("data") or {}).get("replies") or []
71
+ if not rows:
72
+ return None
73
+ r = rows[0]
74
+ return {
75
+ "id": r.get("id"), "platform": r.get("platform"),
76
+ "their_author": r.get("their_author"),
77
+ "their_content": r.get("their_content"),
78
+ "their_comment_url": r.get("their_comment_url"),
79
+ "their_comment_id": r.get("their_comment_id"),
80
+ "depth": r.get("depth"),
81
+ "thread_title": r.get("thread_title"),
82
+ "thread_url": r.get("thread_url"),
83
+ "our_content": r.get("our_content"),
84
+ "our_url": r.get("our_url"),
85
+ }
86
+
87
+
88
+ def get_recent_archetypes(limit=3):
89
+ """Fetch our last N GitHub replies so Claude can vary style across threads.
90
+
91
+ Routes through /api/v1/replies (status=replied, has_our_reply_content,
92
+ ordered by replied_at DESC)."""
93
+ resp = api_get("/api/v1/replies", query={
94
+ "platform": "github",
95
+ "status": "replied",
96
+ "has_our_reply_content": "true",
97
+ "order_by": "replied_at",
98
+ "limit": int(limit),
99
+ })
100
+ rows = ((resp or {}).get("data") or {}).get("replies") or []
101
+ return [r.get("our_reply_content") for r in rows if r.get("our_reply_content")]
102
+
103
+
104
+ def parse_issue_url(url):
105
+ """Extract (owner, repo, number) from a github.com issue or PR URL."""
106
+ if not url:
107
+ return None, None, None
108
+ m = re.search(r"github\.com/([^/]+)/([^/]+)/(?:issues|pull)/(\d+)", url)
109
+ if not m:
110
+ return None, None, None
111
+ return m.group(1), m.group(2), int(m.group(3))
112
+
113
+
114
+ def fetch_thread(owner, repo, number):
115
+ """Fetch full issue thread via gh CLI. Returns dict with title, body, comments."""
116
+ try:
117
+ out = subprocess.check_output(
118
+ ["gh", "issue", "view", str(number), "-R", f"{owner}/{repo}",
119
+ "--json", "title,body,author,state,comments,url"],
120
+ text=True, timeout=30, stderr=subprocess.STDOUT,
121
+ )
122
+ return json.loads(out)
123
+ except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e:
124
+ err = e.output if hasattr(e, "output") and e.output else str(e)
125
+ return {"_error": str(err)[:300]}
126
+ except json.JSONDecodeError as e:
127
+ return {"_error": f"json_decode: {e}"}
128
+
129
+
130
+ def summarize_thread_for_prompt(thread, our_username):
131
+ """Compact the gh issue view JSON into a human-readable string for the prompt.
132
+
133
+ The raw JSON is noisy (association, reactionGroups, etc). We want Claude to
134
+ see a clean chronological transcript: issue body first, then each comment
135
+ with author and body. We tag our own comments explicitly so Claude knows
136
+ what we've already said.
137
+ """
138
+ if "_error" in thread:
139
+ return f"[thread fetch failed: {thread['_error']}]"
140
+
141
+ lines = []
142
+ lines.append(f"Title: {thread.get('title', '(no title)')}")
143
+ lines.append(f"State: {thread.get('state', '?')}")
144
+ author = (thread.get("author") or {}).get("login", "?")
145
+ lines.append(f"Opened by: @{author}")
146
+ lines.append("")
147
+ lines.append("=== Issue body ===")
148
+ lines.append(thread.get("body", "") or "(empty)")
149
+ lines.append("")
150
+ lines.append("=== Comments (chronological) ===")
151
+
152
+ comments = thread.get("comments", []) or []
153
+ for i, c in enumerate(comments, 1):
154
+ c_author = (c.get("author") or {}).get("login", "?")
155
+ is_us = c_author == our_username
156
+ tag = " [THIS IS US]" if is_us else ""
157
+ body = c.get("body", "") or ""
158
+ lines.append(f"\n--- Comment {i} by @{c_author}{tag} ---")
159
+ lines.append(body)
160
+
161
+ text = "\n".join(lines)
162
+ if len(text) > THREAD_CHAR_CAP:
163
+ text = text[:THREAD_CHAR_CAP] + f"\n\n[... truncated, {len(text) - THREAD_CHAR_CAP} chars cut ...]"
164
+ return text
165
+
166
+
167
+ def build_prompt(reply, thread_summary, recent_replies, our_username, owner, repo, number):
168
+ reply_json = json.dumps(reply, indent=2, default=str)
169
+
170
+ recent_context = ""
171
+ if recent_replies:
172
+ snippets = "\n".join(f" - {r}" for r in recent_replies)
173
+ recent_context = f"""
174
+ ## Your last {len(recent_replies)} GitHub replies (vary your style, don't repeat yourself)
175
+ {snippets}
176
+ """
177
+
178
+ return f"""You are the Social Autoposter GitHub issues engagement bot.
179
+
180
+ Your GitHub username is: {our_username}
181
+ Target issue: {owner}/{repo}#{number}
182
+
183
+ ## The triggering comment we need to decide about
184
+ {reply_json}
185
+
186
+ ## Full issue thread
187
+ The entire conversation is below. Our own prior comments are tagged [THIS IS US].
188
+ Read it carefully before deciding anything.
189
+
190
+ {thread_summary}
191
+ {recent_context}
192
+ {get_styles_prompt("github", context="replying")}
193
+
194
+ ## Content rules
195
+ - Write like a technical peer in the thread, not a marketer.
196
+ - NO em dashes. Use commas, periods, or regular dashes.
197
+ - Match the length and register of the thread. Short threads get short replies.
198
+ - Do not promote. Voice (whether you speak AS the maker or as an outside observer) is governed by the VOICE RELATIONSHIP section below; do not override it here.
199
+ - Never link to your own repo or product in a thread that is a bug report for someone else's project. Ever.
200
+
201
+ ## Bot / engagement-loop escape hatch (use sparingly, but use it)
202
+ We maintain a universal author blocklist in Postgres (`author_blocklist`),
203
+ consulted at /api/v1/replies POST time. A single block recorded by ANY of
204
+ our accounts/installs applies to EVERY future engagement from EVERY of our
205
+ accounts — universal scope, by design. The velocity gate already covers
206
+ "this handle has gotten too many replies from us in 24h/7d"; this lane is
207
+ for the LLM-judgment cases velocity cannot catch.
208
+
209
+ When to add a block (your judgment, exercised CONSERVATIVELY):
210
+ - The GitHub handle is plainly an AI/bot account: templated phrasing across
211
+ unrelated issues, generic filler answers, account name pattern like
212
+ `*-bot` / `Foo-AI`, comments are repository drive-by promo
213
+ - We are clearly stuck in a reciprocal engagement loop with this account
214
+ - The handle is comment-spamming across many repos (drive-by self-promo on
215
+ every issue, not actually engaging with the bug)
216
+
217
+ DO NOT block: a maintainer we disagree with, a hostile-but-human critic,
218
+ a low-quality but human comment, or a single bad interaction. Skip those
219
+ (action='skip') — blocking is permanent until manually removed and applies
220
+ to all our accounts.
221
+
222
+ How to use it: do NOT emit a reply for this row. Instead, output the skip
223
+ JSON with reason='blocklist_added:HANDLE', and the orchestrator's
224
+ post-decision step will run reply_db.py blocklist add for you when the
225
+ reason starts with `blocklist_added:`. The handle to pass is the GitHub
226
+ login (e.g. for github.com/octocat, pass octocat). If you also want a
227
+ classification, output reason='blocklist_added:HANDLE:bot' or
228
+ 'blocklist_added:HANDLE:engagement_loop'.
229
+
230
+ {get_voice_relationship_rule()}
231
+
232
+ {get_anti_patterns()}
233
+
234
+ ## Tiered link strategy (DEFAULT TO TIER 1)
235
+ - Tier 1 (default): No link, no project mention. Just substance that helps the reader.
236
+ - Tier 2: Only if the thread is explicitly about a topic one of our projects solves AND nobody has offered a comparable tool yet AND the maintainer hasn't already resolved it. Mention casually.
237
+ - Tier 3: Only if someone explicitly asks "what do you use" / "any tools for this" / "link?". Then give it directly.
238
+
239
+ ## Decision step: reply or skip?
240
+
241
+ Read the FULL thread above. There is NO cap on how many times we can reply to a thread. Active back-and-forth is encouraged when the conversation keeps developing and we have something useful to contribute. Do not skip just because we have prior comments in the thread. Skip only when one of the specific conditions below is clearly true.
242
+
243
+ DEFAULT TO REPLY when you have substance. Lean toward engagement, not silence.
244
+
245
+ SKIP (output action=skip) only when one of these is clearly true:
246
+ - light_acknowledgment: the triggering comment is just thanks, emoji, +1, or other content-free acknowledgment
247
+ - not_directed_at_us: the comment is in a conversation between two other people in the thread and does not ask us anything. Prefer this reason whenever the comment is addressed to someone else by @mention or context, regardless of how many prior comments we've made.
248
+ - no_value_to_add: the specific question or point has already been answered in the thread by someone else, or our reply would just repeat something we or others already said. This is about content, not count.
249
+ - conversation_concluded: the issue has been resolved, a fix has shipped, the maintainer closed it with an answer, and there is nothing substantive left to discuss. This is about thread state, not count.
250
+ - hostile_or_flagged: our prior comments in this thread were flagged as spam, someone called us a bot, or we are being accused of shilling. Back off.
251
+ - off_topic_for_us: the discussion is outside our expertise or unrelated to anything in config.json
252
+ - self_promo_risk: any honest reply would inevitably sound like self-promotion and there is no way to be genuinely helpful without it
253
+
254
+ REPLY (output action=reply with text) when any of these is true:
255
+ - The comment asks a direct question we can answer with useful insight
256
+ - We have specific technical substance to contribute that is not already in the thread
257
+ - The conversation is still alive and a peer reading it would find our next reply useful
258
+ - It is fine to be the 5th, 10th, or 20th reply from our account. Count does not matter. Substance does.
259
+
260
+ ## Output format
261
+ Output ONLY ONE JSON object. No markdown, no prose, no explanations, no code fences.
262
+
263
+ For skip:
264
+ {{"action": "skip", "reason": "REASON_FROM_LIST_ABOVE"}}
265
+
266
+ For reply:
267
+ {{"action": "reply", "text": "YOUR_REPLY_TEXT", "project": null, "engagement_style": "STYLE_NAME"}}
268
+
269
+ Set "engagement_style" to the style you chose from the list above. Every reply MUST have an engagement_style. If none of the listed styles fit, you may invent a new one: set engagement_style to your new name AND include a `new_style` block (description, example, note, why_existing_didnt_fit) inside the same JSON object, per the "Inventing a new style" instructions above.
270
+ If you recommended a project from config.json in the reply text, set "project" to that project name.
271
+ The orchestrator posts the reply via gh CLI and updates the database. You only decide and draft.
272
+ """
273
+
274
+
275
+ def run_claude(prompt, timeout=300, session_id=None):
276
+ """Run claude -p with the given prompt. Returns (success, output, usage_dict).
277
+
278
+ Streams output in real time to stderr for log visibility. Mirrors
279
+ engage_reddit.py exactly.
280
+ """
281
+ import time as _time
282
+ import select
283
+ usage = {"input_tokens": 0, "output_tokens": 0, "cache_read": 0, "cache_create": 0, "cost_usd": 0.0}
284
+ cmd = ["claude", "-p", "--output-format", "stream-json", "--verbose"]
285
+ if session_id:
286
+ cmd += ["--session-id", session_id]
287
+ cmd += ["--tools", "Read"]
288
+ env = os.environ.copy()
289
+ env.pop("ANTHROPIC_API_KEY", None) # use OAuth, not API key
290
+ if session_id:
291
+ env["CLAUDE_SESSION_ID"] = session_id
292
+ try:
293
+ proc = subprocess.Popen(
294
+ cmd, env=env, stdin=subprocess.PIPE,
295
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True,
296
+ )
297
+ proc.stdin.write(prompt)
298
+ proc.stdin.close()
299
+ collected = []
300
+ deadline = _time.time() + timeout
301
+ while True:
302
+ remaining = deadline - _time.time()
303
+ if remaining <= 0:
304
+ proc.kill()
305
+ return False, "TIMEOUT", usage
306
+ ready, _, _ = select.select([proc.stdout], [], [], min(remaining, 30))
307
+ if ready:
308
+ line = proc.stdout.readline()
309
+ if not line:
310
+ break
311
+ collected.append(line)
312
+ try:
313
+ evt = json.loads(line.strip())
314
+ etype = evt.get("type", "")
315
+ if etype == "assistant":
316
+ msg = evt.get("message", {})
317
+ for block in msg.get("content", []):
318
+ if block.get("type") == "tool_use":
319
+ tool_name = block.get("name", "")
320
+ tool_in = str(block.get("input", {}))[:120]
321
+ print(f"[engage_github] tool: {tool_name} | {tool_in}",
322
+ file=sys.stderr, flush=True)
323
+ elif block.get("type") == "text" and block.get("text", "").strip():
324
+ txt = block["text"].strip()[:200]
325
+ print(f"[engage_github] {txt}", file=sys.stderr, flush=True)
326
+ elif etype == "result":
327
+ print(f"[engage_github] done: cost=${evt.get('total_cost_usd', 0):.4f}",
328
+ file=sys.stderr, flush=True)
329
+ except (json.JSONDecodeError, TypeError):
330
+ print(f"[engage_github] {line.rstrip()[:200]}", file=sys.stderr, flush=True)
331
+ elif proc.poll() is not None:
332
+ rest = proc.stdout.read()
333
+ if rest:
334
+ collected.append(rest)
335
+ break
336
+ else:
337
+ elapsed_s = int(_time.time() - (deadline - timeout))
338
+ print(f"[engage_github] ... still running ({elapsed_s}s)",
339
+ file=sys.stderr, flush=True)
340
+ proc.wait()
341
+ text_output = ""
342
+ for line_str in collected:
343
+ line_str = line_str.strip()
344
+ if not line_str:
345
+ continue
346
+ try:
347
+ event = json.loads(line_str)
348
+ if event.get("type") == "result":
349
+ text_output = event.get("result", "")
350
+ usage["cost_usd"] = event.get("total_cost_usd", 0.0)
351
+ u = event.get("usage", {})
352
+ usage["input_tokens"] = u.get("input_tokens", 0)
353
+ usage["output_tokens"] = u.get("output_tokens", 0)
354
+ usage["cache_read"] = u.get("cache_read_input_tokens", 0)
355
+ usage["cache_create"] = u.get("cache_creation_input_tokens", 0)
356
+ except (json.JSONDecodeError, TypeError):
357
+ pass
358
+ if not text_output:
359
+ text_output = "".join(collected)
360
+ stderr_out = proc.stderr.read() if proc.stderr else ""
361
+ return proc.returncode == 0, text_output + stderr_out, usage
362
+ except Exception as e:
363
+ return False, str(e), usage
364
+
365
+
366
+ def parse_decision(output):
367
+ """Extract the action JSON object from Claude's output. Returns dict or None."""
368
+ # Try strict object first: balanced braces containing "action":"..."
369
+ # Claude may wrap in ``` or add prose; scan for any {...} containing "action"
370
+ candidates = re.findall(r'\{[^{}]*"action"\s*:\s*"[^"]+?"[^{}]*\}', output, re.DOTALL)
371
+ for c in candidates:
372
+ try:
373
+ return json.loads(c)
374
+ except (json.JSONDecodeError, TypeError):
375
+ continue
376
+ # Fallback: find the last JSON-looking object
377
+ try:
378
+ start = output.rfind("{")
379
+ end = output.rfind("}")
380
+ if start != -1 and end > start:
381
+ return json.loads(output[start:end + 1])
382
+ except (json.JSONDecodeError, TypeError):
383
+ pass
384
+ return None
385
+
386
+
387
+ def post_comment(owner, repo, number, body):
388
+ """Post a comment via gh CLI. Returns (ok, url_or_error_string)."""
389
+ try:
390
+ out = subprocess.check_output(
391
+ ["gh", "issue", "comment", str(number), "-R", f"{owner}/{repo}", "--body", body],
392
+ text=True, timeout=60, stderr=subprocess.STDOUT,
393
+ )
394
+ url = None
395
+ for line in out.strip().splitlines():
396
+ if line.startswith("https://github.com"):
397
+ url = line.strip()
398
+ break
399
+ return True, url
400
+ except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e:
401
+ err = e.output if hasattr(e, "output") and e.output else str(e)
402
+ return False, str(err)[:300]
403
+
404
+
405
+ def main():
406
+ parser = argparse.ArgumentParser(description="GitHub issues engagement (one at a time, thread-aware)")
407
+ parser.add_argument("--dry-run", action="store_true",
408
+ help="Print prompt for first pending reply without executing Claude")
409
+ parser.add_argument("--limit", type=int, default=0,
410
+ help="Max replies to process (0 = unlimited)")
411
+ parser.add_argument("--timeout", type=int, default=3600,
412
+ help="Global timeout in seconds")
413
+ parser.add_argument("--per-reply-timeout", type=int, default=300,
414
+ help="Timeout per claude session in seconds")
415
+ args = parser.parse_args()
416
+
417
+ config = load_config()
418
+ excluded_authors = {a.lower() for a in config.get("exclusions", {}).get("authors", [])}
419
+ excluded_repos = {r.lower() for r in config.get("exclusions", {}).get("github_repos", [])}
420
+ # Auto-blocklist: owners with >=2 moderated posts in last 90 days. Same
421
+ # source of truth as github_tools.py cmd_search uses for new candidates.
422
+ # (HTTP-only now; per-account scoping is resolved inside the helper.)
423
+ from github_tools import _dynamic_owner_blocklist
424
+ excluded_repos = excluded_repos | _dynamic_owner_blocklist()
425
+ our_username = config.get("accounts", {}).get("github", {}).get("username", "m13v")
426
+
427
+ start_time = time.time()
428
+ processed = 0
429
+ succeeded = 0
430
+ skipped = 0
431
+ failed = 0
432
+ total_usage = {"input_tokens": 0, "output_tokens": 0, "cache_read": 0, "cache_create": 0, "cost_usd": 0.0}
433
+
434
+ consecutive_failures = 0
435
+ last_failed_id = None
436
+
437
+ print(f"[engage_github] Starting. limit={args.limit or 'unlimited'}, timeout={args.timeout}s, user={our_username}")
438
+
439
+ while True:
440
+ if time.time() - start_time > args.timeout:
441
+ print(f"[engage_github] Global timeout reached ({args.timeout}s). Stopping.")
442
+ break
443
+ if args.limit and processed >= args.limit:
444
+ print(f"[engage_github] Limit reached ({args.limit}). Stopping.")
445
+ break
446
+ if consecutive_failures >= 3:
447
+ print(f"[engage_github] 3 consecutive Claude failures (likely rate limit). Stopping.")
448
+ break
449
+
450
+ reply = get_next_pending()
451
+ if not reply:
452
+ print("[engage_github] No pending replies. Done!")
453
+ break
454
+
455
+ # Exclusion: author
456
+ if (reply["their_author"] or "").lower() in excluded_authors:
457
+ subprocess.run([PYTHON, REPLY_DB, "skipped", str(reply["id"]), "excluded_author"])
458
+ print(f"[engage_github] #{reply['id']} skipped (excluded_author: {reply['their_author']})")
459
+ skipped += 1
460
+ processed += 1
461
+ continue
462
+
463
+ # Parse owner/repo/number from thread_url
464
+ owner, repo, number = parse_issue_url(reply["thread_url"] or "")
465
+ if not owner:
466
+ subprocess.run([PYTHON, REPLY_DB, "skipped", str(reply["id"]), "bad_thread_url"])
467
+ print(f"[engage_github] #{reply['id']} skipped (bad_thread_url: {reply['thread_url']})")
468
+ skipped += 1
469
+ processed += 1
470
+ continue
471
+
472
+ # Exclusion: repo
473
+ repo_key = f"{owner}/{repo}".lower()
474
+ if repo_key in excluded_repos or owner.lower() in excluded_repos:
475
+ subprocess.run([PYTHON, REPLY_DB, "skipped", str(reply["id"]), "excluded_repo"])
476
+ print(f"[engage_github] #{reply['id']} skipped (excluded_repo: {repo_key})")
477
+ skipped += 1
478
+ processed += 1
479
+ continue
480
+
481
+ # Fetch the full thread
482
+ print(f"[engage_github] Fetching thread for {owner}/{repo}#{number}")
483
+ thread = fetch_thread(owner, repo, number)
484
+ if "_error" in thread:
485
+ subprocess.run([PYTHON, REPLY_DB, "skipped", str(reply["id"]),
486
+ f"fetch_error: {thread['_error']}"])
487
+ print(f"[engage_github] #{reply['id']} skipped (fetch_error: {thread['_error'][:100]})")
488
+ skipped += 1
489
+ processed += 1
490
+ continue
491
+
492
+ thread_summary = summarize_thread_for_prompt(thread, our_username)
493
+ recent = get_recent_archetypes(limit=3)
494
+ prompt = build_prompt(reply, thread_summary, recent, our_username, owner, repo, number)
495
+
496
+ if args.dry_run:
497
+ print(f"=== DRY RUN: Prompt for reply #{reply['id']} ===")
498
+ print(prompt)
499
+ print("=== END DRY RUN ===")
500
+ break
501
+
502
+ reply_start = time.time()
503
+ session_id = str(uuid.uuid4())
504
+ os.environ["CLAUDE_SESSION_ID"] = session_id
505
+ session_started_at = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.000Z")
506
+ print(f"[engage_github] Processing #{reply['id']} from @{reply['their_author']} "
507
+ f"on {owner}/{repo}#{number}")
508
+
509
+ ok, output, usage = run_claude(prompt, timeout=args.per_reply_timeout, session_id=session_id)
510
+ reply_elapsed = time.time() - reply_start
511
+ session_ended_at = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.000Z")
512
+ log_args = [PYTHON, os.path.join(REPO_DIR, "scripts", "log_claude_session.py"),
513
+ "--session-id", session_id, "--script", "engage_github",
514
+ "--started-at", session_started_at, "--ended-at", session_ended_at]
515
+ orch_cost = usage.get("cost_usd")
516
+ if isinstance(orch_cost, (int, float)) and orch_cost > 0:
517
+ log_args.extend(["--orchestrator-cost-usd", str(orch_cost)])
518
+ subprocess.run(log_args, capture_output=True)
519
+
520
+ for k in total_usage:
521
+ total_usage[k] += usage[k]
522
+
523
+ if not ok:
524
+ failed += 1
525
+ consecutive_failures += 1
526
+ # Mark as skipped so the loop advances to the next pending reply
527
+ subprocess.run([PYTHON, REPLY_DB, "skipped", str(reply["id"]), "claude_error"],
528
+ capture_output=True)
529
+ print(f"[engage_github] #{reply['id']} CLAUDE FAILED ({reply_elapsed:.0f}s): {output[:200]}")
530
+ else:
531
+ consecutive_failures = 0
532
+ decision = parse_decision(output)
533
+ if not decision:
534
+ failed += 1
535
+ print(f"[engage_github] #{reply['id']} BAD OUTPUT ({reply_elapsed:.0f}s): {output[:300]}")
536
+ elif decision.get("action") == "skip":
537
+ reason = decision.get("reason", "unknown") or "unknown"
538
+ # Bot/engagement-loop escape hatch. The github engage prompt
539
+ # is run with --tools Read only (no Bash), so the model
540
+ # cannot shell out to reply_db.py blocklist add itself.
541
+ # Instead it signals via the skip reason pattern
542
+ # `blocklist_added:HANDLE[:classification]` and the
543
+ # orchestrator records the block here. HANDLE defaults to
544
+ # the reply's their_author when the model omits it (which
545
+ # is the common case for github since the author IS the
546
+ # GitHub login).
547
+ if reason.startswith("blocklist_added"):
548
+ parts = reason.split(":")
549
+ handle = parts[1].strip() if len(parts) > 1 and parts[1].strip() else (reply["their_author"] or "").strip()
550
+ classification = parts[2].strip() if len(parts) > 2 and parts[2].strip() in ("bot", "engagement_loop") else "bot"
551
+ if handle:
552
+ bl_cmd = [
553
+ PYTHON, REPLY_DB, "blocklist", "add",
554
+ "github_issues", handle,
555
+ "--reason", f"engage_llm judgment: {reason}",
556
+ "--classification", classification,
557
+ "--severity", "hard",
558
+ "--source-reply-id", str(reply["id"]),
559
+ ]
560
+ bl_res = subprocess.run(bl_cmd, capture_output=True, text=True)
561
+ if bl_res.returncode == 0:
562
+ print(f"[engage_github] blocklist add github_issues/{handle} cls={classification}")
563
+ else:
564
+ print(f"[engage_github] blocklist add FAILED for {handle}: {bl_res.stderr[:200]}")
565
+ subprocess.run([PYTHON, REPLY_DB, "skipped", str(reply["id"]), reason])
566
+ skipped += 1
567
+ print(f"[engage_github] #{reply['id']} SKIPPED: {reason} ({reply_elapsed:.0f}s) "
568
+ f"[${usage['cost_usd']:.4f}]")
569
+ elif decision.get("action") == "reply":
570
+ reply_text = (decision.get("text") or "").strip()
571
+ project = decision.get("project")
572
+ if not reply_text:
573
+ subprocess.run([PYTHON, REPLY_DB, "skipped", str(reply["id"]), "empty_reply_text"])
574
+ failed += 1
575
+ print(f"[engage_github] #{reply['id']} empty reply text, marked skipped")
576
+ else:
577
+ subprocess.run([PYTHON, REPLY_DB, "processing", str(reply["id"])])
578
+ ok_post, url_or_err = post_comment(owner, repo, number, reply_text)
579
+ if ok_post:
580
+ cmd_args = [PYTHON, REPLY_DB, "replied", str(reply["id"]), reply_text]
581
+ if url_or_err:
582
+ cmd_args.append(url_or_err)
583
+ style = decision.get("engagement_style", "")
584
+ if style:
585
+ if not url_or_err:
586
+ cmd_args.append("") # placeholder for url
587
+ cmd_args.append(style)
588
+ subprocess.run(cmd_args)
589
+ if project:
590
+ subprocess.run(
591
+ [PYTHON, REPLY_DB, "set_project", str(reply["id"]), project],
592
+ capture_output=True,
593
+ )
594
+ succeeded += 1
595
+ print(f"[engage_github] #{reply['id']} POSTED ({reply_elapsed:.0f}s) "
596
+ f"[${usage['cost_usd']:.4f}] -> {url_or_err or '(no url)'}")
597
+ else:
598
+ subprocess.run([PYTHON, REPLY_DB, "skipped", str(reply["id"]),
599
+ f"post_error: {url_or_err}"])
600
+ failed += 1
601
+ print(f"[engage_github] #{reply['id']} POST FAILED: {url_or_err}")
602
+ else:
603
+ failed += 1
604
+ print(f"[engage_github] #{reply['id']} unknown action: {decision}")
605
+
606
+ print(f"[engage_github] #{reply['id']} tokens: in={usage['input_tokens']} "
607
+ f"out={usage['output_tokens']} cache_r={usage['cache_read']} "
608
+ f"cache_w={usage['cache_create']} ${usage['cost_usd']:.4f}")
609
+
610
+ processed += 1
611
+ time.sleep(2)
612
+
613
+ total_elapsed = time.time() - start_time
614
+ print(f"\n[engage_github] === SUMMARY ===")
615
+ print(f"[engage_github] processed={processed} succeeded={succeeded} "
616
+ f"skipped={skipped} failed={failed} elapsed={total_elapsed:.0f}s")
617
+ print(f"[engage_github] Total tokens: input={total_usage['input_tokens']} "
618
+ f"output={total_usage['output_tokens']} "
619
+ f"cache_read={total_usage['cache_read']} cache_create={total_usage['cache_create']}")
620
+ print(f"[engage_github] Total cost: ${total_usage['cost_usd']:.4f}")
621
+ if succeeded > 0:
622
+ print(f"[engage_github] Avg cost per reply: ${total_usage['cost_usd'] / succeeded:.4f}")
623
+
624
+ # Canonical machine-readable summary line. github-engage.sh greps this and
625
+ # writes ONE log_run.py row that also carries Phase A scan counters. See
626
+ # the comment in engage_reddit.py for the duplicate-row history.
627
+ print(
628
+ f"[engage_github] LOG_RUN_SUMMARY"
629
+ f" posted={succeeded}"
630
+ f" skipped={skipped}"
631
+ f" failed={failed}"
632
+ f" cost={total_usage['cost_usd']:.4f}"
633
+ f" elapsed={int(total_elapsed)}"
634
+ )
635
+
636
+ subprocess.run([PYTHON, REPLY_DB, "status"])
637
+
638
+
639
+ if __name__ == "__main__":
640
+ main()