@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,1593 @@
1
+ #!/bin/bash
2
+ # Social Autoposter - LinkedIn posting (Phase A discover+score, Phase B post)
3
+ #
4
+ # Phase A (discovery + scoring, ~$10-15 target): pick a project, consult
5
+ # top/dud query history, draft 8 dynamic search queries, browse the
6
+ # LinkedIn SERPs, extract engagement metrics (reactions/comments/reposts/
7
+ # age/author) for every visible candidate, score serp quality, write a
8
+ # structured JSON envelope to a tmp file, STOP. Bash then pipes the
9
+ # envelope into:
10
+ # - log_linkedin_search_attempts.py (records every query, including
11
+ # zero-result and low-quality, so duds get blocked next cycle)
12
+ # - score_linkedin_candidates.py (computes velocity + virality, upserts
13
+ # into linkedin_candidates, dedupes against engaged URN history)
14
+ # Bash then SELECTs the top pending candidate by velocity_score.
15
+ #
16
+ # Phase B (compose + post + verify + log, ~$10-15 target): given Phase A's
17
+ # chosen candidate (already in linkedin_candidates), navigate straight to
18
+ # the URL, defensively re-check engaged-ids, draft using the project's
19
+ # voice block + engagement styles + top performers report, post via
20
+ # the linkedin-harness MCP (bh_run), verify (DOM + screenshot), log via
21
+ # log_post.py, mark the candidate row 'posted' (or 'skipped'), STOP.
22
+ #
23
+ # Differences vs the pre-2026-04-29 shape:
24
+ # - Phase A extracts ENGAGEMENT (not just URN); we don't fly blind anymore
25
+ # - Phase A logs every search query (positive, zero, low-quality SERP) so
26
+ # the LLM learns which phrasings work and which to retire
27
+ # - Phase B reads its candidate from linkedin_candidates (DB-backed),
28
+ # not from a file, so the same candidate isn't picked twice across runs
29
+
30
+ set -euo pipefail
31
+
32
+ # ===== Whole-run singleton guard (2026-05-30) =====
33
+ # launchd (com.m13v.social-linkedin) fires this script every 900s (15 min),
34
+ # but a full Phase A + Phase B run takes 20+ min. Without a run-level mutex,
35
+ # two fires overlap and BOTH drive the single linkedin-harness Chrome
36
+ # (port 9556) at once: one run searches SERPs (Phase A) while the other
37
+ # posts a comment (Phase B), yanking the same window back and forth. That is
38
+ # the "two LinkedIns running in parallel" symptom (proven via the browser
39
+ # activity log on 2026-05-30: pids 35789 Phase A + 59215 Phase B alive
40
+ # together, both on 9556). The per-phase locks do NOT prevent this because
41
+ # they release between phases. This guard makes the ENTIRE run a singleton:
42
+ # if a prior run-linkedin.sh is still alive, this fire exits immediately.
43
+ S4L_LI_RUN_LOCK="/tmp/saps-run-linkedin.lock"
44
+ if mkdir "$S4L_LI_RUN_LOCK" 2>/dev/null; then
45
+ echo $$ > "$S4L_LI_RUN_LOCK/pid"
46
+ else
47
+ _li_holder="$(cat "$S4L_LI_RUN_LOCK/pid" 2>/dev/null || echo "")"
48
+ if [ -n "$_li_holder" ] && kill -0 "$_li_holder" 2>/dev/null; then
49
+ echo "[run-linkedin] singleton guard: prior full run (pid $_li_holder) still alive; exiting this fire to avoid two drivers on the 9556 Chrome"
50
+ exit 0
51
+ fi
52
+ # holder is dead -> stale lock; reclaim it
53
+ echo "[run-linkedin] singleton guard: reclaiming stale run lock (dead pid ${_li_holder:-unknown})"
54
+ rm -rf "$S4L_LI_RUN_LOCK"
55
+ mkdir "$S4L_LI_RUN_LOCK" && echo $$ > "$S4L_LI_RUN_LOCK/pid"
56
+ fi
57
+
58
+ # Transport backend selector (2026-05-28). Two interchangeable paths for the
59
+ # only two browser touchpoints (Phase A SERP search, Phase B comment-post):
60
+ # browser (DEFAULT, ACTIVE) — headed-Chrome path via the linkedin-harness
61
+ # MCP (bh_run). This is what every real run uses.
62
+ # unipile (DISABLED / OFF) — UniPile REST API via scripts/linkedin_unipile.py.
63
+ # *** DO NOT ASSUME THIS PATH IS RUNNING. ***
64
+ # The UniPile-hosted LinkedIn session is dead (it logs
65
+ # itself out and returns 503 no_client_session), which
66
+ # silently zeroed out every discovery cycle. It is now
67
+ # gated OFF behind the default flip below and is only
68
+ # reachable by an explicit LINKEDIN_BACKEND=unipile
69
+ # override (which will still 503 until someone manually
70
+ # reconnects the UniPile account). All the unipile-branch
71
+ # code below (the `if [ "$LINKEDIN_BACKEND" = "unipile" ]`
72
+ # blocks, linkedin_unipile.py calls) is DORMANT, kept only
73
+ # so the path can be revived later. Seeing it in the file
74
+ # does NOT mean it is in use.
75
+ # Everything ELSE (project pick, query drafting, SERP-quality rating, dedup,
76
+ # velocity/virality scoring, voice composition, URL wrapping, log_post.py
77
+ # logging, candidate marking) is byte-for-byte identical across both paths.
78
+ # Override per-run (revives the dormant, currently-broken path):
79
+ # LINKEDIN_BACKEND=unipile ~/social-autoposter/skill/run-linkedin.sh
80
+ LINKEDIN_BACKEND="${LINKEDIN_BACKEND:-browser}"
81
+
82
+ # LinkedIn killswitch (2026-05-27): refuse to run if a prior fire detected
83
+ # session compromise (http_999, authwall, throttle, li_at cleared).
84
+ # State: ~/.claude/social-autoposter/linkedin.killswitch
85
+ # Clear: python3 ~/social-autoposter/scripts/linkedin_killswitch.py clear
86
+ # Only gates the browser backend — UniPile has no headed session to compromise.
87
+ if [ "$LINKEDIN_BACKEND" = "browser" ] && [ -f "$HOME/.claude/social-autoposter/linkedin.killswitch" ]; then
88
+ echo "[$(date +%H:%M:%S)] LINKEDIN_KILLSWITCH active. Aborting LinkedIn pipeline."
89
+ echo " Re-auth LinkedIn in harness Chrome, then: python3 ~/social-autoposter/scripts/linkedin_killswitch.py clear"
90
+ exit 0
91
+ fi
92
+
93
+ [ -f "$HOME/social-autoposter/.env" ] && source "$HOME/social-autoposter/.env"
94
+
95
+ REPO_DIR="$HOME/social-autoposter"
96
+ SKILL_FILE="$REPO_DIR/SKILL.md"
97
+ LOG_DIR="$REPO_DIR/skill/logs"
98
+ mkdir -p "$LOG_DIR"
99
+ LOG_FILE="$LOG_DIR/run-linkedin-$(date +%Y-%m-%d_%H%M%S).log"
100
+ RUN_START_EPOCH=$(date +%s)
101
+ BATCH_ID="li-$(date +%Y%m%d_%H%M%S)-$$"
102
+ # Export as SA_CYCLE_ID so log_claude_session.py stamps cycle_id on every
103
+ # claude_sessions row spawned by this cycle. Enables per-cycle cost queries
104
+ # via get_run_cost.py --cycle-id. 2026-05-10 cycle_id rollout.
105
+ export SA_CYCLE_ID="$BATCH_ID"
106
+
107
+ echo "=== LinkedIn Post Run: $(date) (batch=$BATCH_ID) ===" | tee "$LOG_FILE"
108
+
109
+ # 2026-05-01: lock policy was changed from "hold for the entire run" to
110
+ # "hold only while a Claude phase is actively driving the browser". The old
111
+ # policy meant a single 25-45min cycle held linkedin-browser exclusively for
112
+ # its full duration, which (a) starved peer pipelines (dm-replies-linkedin,
113
+ # audit-linkedin, link-edit-linkedin) of any browser window and (b) defeated
114
+ # the launchd 15-min cadence: every fire of this job had to wait for the
115
+ # prior fire's full pipeline to finish. The browser is only actually used
116
+ # inside the two run_claude.sh invocations (Phase A discovery, Phase B
117
+ # post). All the work between them (envelope validate, DB ingest, candidate
118
+ # pick, project config, top performers, styles, etc.) is pure DB/CPU and
119
+ # does not need the lock. So we acquire just before each Claude phase and
120
+ # release immediately after.
121
+ source "$REPO_DIR/skill/lock.sh"
122
+ # Browser backend bootstrap (linkedin-harness). Sets MCP_CONFIG_FILE,
123
+ # BROWSER_INSTRUCTIONS, exports LINKEDIN_CDP_URL (so discover_linkedin_candidates.py
124
+ # CDP-attaches to the harness Chrome on 9556), and provides
125
+ # ensure_linkedin_browser_for_backend. Only the LINKEDIN_BACKEND=browser path
126
+ # uses these; the unipile (default) path has no browser. Migrated off the
127
+ # deprecated linkedin-agent MCP on 2026-05-29 (mirrors the Twitter migration).
128
+ source "$REPO_DIR/skill/lib/linkedin-backend.sh"
129
+
130
+ # Idempotent run_monitor.log emitter wired into a chained EXIT/INT/TERM/HUP
131
+ # trap. Without this, SIGTERM landing between Phase B post (where Claude has
132
+ # already submitted the comment via the LinkedIn API and the row is in the
133
+ # `posts` table) and the inline summary write at the bottom of the script
134
+ # silently drops the run from run_monitor.log. Mirrors the same fix shipped
135
+ # to run-reddit-search.sh and run-twitter-cycle.sh.
136
+ #
137
+ # Reads counters from globals the cycle accumulates (RUN_START_EPOCH,
138
+ # PB_RC, LOG_FILE) and re-derives POSTED/SKIPPED/FAILED the same way the
139
+ # inline block does. All shell-outs are wrapped in `timeout 10` so a Postgres
140
+ # hang during shutdown can't wedge the trap.
141
+ #
142
+ # Early-exit failure paths (Phase A no-candidates, etc.) write their own
143
+ # tailored log_run.py line and then set _SA_RUN_SUMMARY_EMITTED=1 to
144
+ # short-circuit this function — the trap fires, no-ops, and the dedicated
145
+ # error reason stays.
146
+ _SA_RUN_SUMMARY_EMITTED=0
147
+ _sa_emit_run_summary_oneshot() {
148
+ [ "${_SA_RUN_SUMMARY_EMITTED:-0}" = "1" ] && return 0
149
+ _SA_RUN_SUMMARY_EMITTED=1
150
+
151
+ local elapsed window_sec posted skipped failed cost
152
+ elapsed=$(( $(date +%s) - ${RUN_START_EPOCH:-$(date +%s)} ))
153
+ window_sec=$(( elapsed + 60 ))
154
+ posted=0
155
+ posted=$(WINDOW_SEC="$window_sec" timeout 15 python3 - <<'PY' 2>/dev/null || true
156
+ import os, sys
157
+ sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)) if "__file__" in dir() else os.getcwd(), "scripts"))
158
+ sys.path.insert(0, os.path.expanduser("~/social-autoposter/scripts"))
159
+ try:
160
+ from http_api import api_get
161
+ win = int(os.environ.get("WINDOW_SEC") or "0")
162
+ resp = api_get("/api/v1/posts/count",
163
+ {"platform": "linkedin", "within_seconds": win})
164
+ print(int((resp.get("data") or {}).get("count") or 0))
165
+ except Exception:
166
+ print(0)
167
+ PY
168
+ )
169
+ [ -z "$posted" ] && posted=0
170
+ skipped=0
171
+ if [ "$posted" = "0" ] && [ -n "${LOG_FILE:-}" ] && [ -f "${LOG_FILE:-}" ] \
172
+ && grep -qE "PHASE_B_SKIP_POST_UNAVAILABLE|## Already engaged|## Comment soft-blocked" "$LOG_FILE" 2>/dev/null; then
173
+ skipped=1
174
+ fi
175
+ failed=0
176
+ if [ "${PB_RC:-1}" -ne 0 ] && [ "$posted" = "0" ] && [ "$skipped" = "0" ]; then
177
+ failed=1
178
+ fi
179
+ cost=$(timeout 10 python3 "$REPO_DIR/scripts/get_run_cost.py" \
180
+ --since "${RUN_START_EPOCH:-0}" \
181
+ --scripts "run-linkedin-phaseA" "run-linkedin-phaseB" \
182
+ 2>/dev/null || echo "0.0000")
183
+ # Surface Anthropic-side cause (stream_idle_timeout, monthly_limit,
184
+ # api_overloaded, context_overflow, credit_balance) when failed>0 so
185
+ # the dashboard pill carries the actual error class instead of just
186
+ # showing a silent failed=1 row. Uses ${var:+...} conditional expansion
187
+ # rather than an empty bash array to avoid the `set -u` empty-array
188
+ # pitfall documented in CLAUDE.md (bash 3.2 trips on `"${empty[@]}"`).
189
+ local lk_reason=""
190
+ if [ "$failed" -gt 0 ] && [ -n "${LOG_FILE:-}" ] && [ -f "${LOG_FILE:-}" ]; then
191
+ lk_reason=$(python3 "$REPO_DIR/scripts/classify_run_error.py" "$LOG_FILE" 2>/dev/null)
192
+ fi
193
+ python3 "$REPO_DIR/scripts/log_run.py" --script post_linkedin \
194
+ --posted "$posted" --skipped "$skipped" --failed "$failed" \
195
+ --cost "$cost" --elapsed "$elapsed" \
196
+ ${lk_reason:+--failure-reasons "${lk_reason}:1"} 2>/dev/null || true
197
+ }
198
+
199
+ # Trap chain: lock.sh has already installed _sa_release_locks on
200
+ # EXIT INT TERM HUP. Replace with a chained handler so summary fires first,
201
+ # then locks release. _sa_release_locks remains in scope after sourcing.
202
+ trap '_sa_emit_run_summary_oneshot; _sa_release_locks; rm -rf "$S4L_LI_RUN_LOCK" 2>/dev/null || true' EXIT INT TERM HUP
203
+
204
+ # ===== Phase A: discovery + scoring =====
205
+ python3 "$REPO_DIR/scripts/linkedin_search_topic_schema.py" 2>>"$LOG_FILE" || true
206
+
207
+ PROJECT_DIST=$(python3 "$REPO_DIR/scripts/pick_project.py" --platform linkedin --distribution 2>/dev/null || echo "(distribution unavailable)")
208
+
209
+ # Mirror Twitter's ownership boundary: Python picks exactly one project and
210
+ # one project_search_topics row before Claude drafts literal LinkedIn queries.
211
+ set +e
212
+ PROJECT_PICK_JSON=$(REPO_DIR="$REPO_DIR" python3 - <<'PY' 2>>"$LOG_FILE"
213
+ import json
214
+ import os
215
+ import sys
216
+
217
+ repo = os.environ["REPO_DIR"]
218
+ sys.path.insert(0, os.path.join(repo, "scripts"))
219
+
220
+ from pick_project import load_config, pick_project
221
+ from pick_search_topic import pick_topic_for_project
222
+
223
+ project = pick_project(load_config(), platform="linkedin")
224
+ if not project:
225
+ raise SystemExit("no LinkedIn-eligible project with active search_topics")
226
+
227
+ name = project.get("name") or ""
228
+ assignment = pick_topic_for_project(name, platform="linkedin")
229
+ topic = (assignment.get("search_topic") or "").strip()
230
+ if not topic:
231
+ raise SystemExit(f"no search_topic picked for project={name!r}")
232
+
233
+ out = {
234
+ "name": name,
235
+ "description": project.get("description", ""),
236
+ "qualification": project.get("qualification", ""),
237
+ "search_topic": topic,
238
+ "picked_weight_pct": assignment.get("picked_weight_pct"),
239
+ "topic_assignment": assignment,
240
+ "reference_topics": assignment.get("reference_topics") or [],
241
+ }
242
+ print(json.dumps(out, indent=2))
243
+ PY
244
+ )
245
+ PICK_RC=$?
246
+ set -e
247
+
248
+ if [ "$PICK_RC" -ne 0 ] || [ -z "${PROJECT_PICK_JSON:-}" ]; then
249
+ echo "Phase A: project/search_topic picker failed. Skipping LinkedIn run." | tee -a "$LOG_FILE"
250
+ ELAPSED=$(( $(date +%s) - RUN_START_EPOCH ))
251
+ _COST=$(python3 "$REPO_DIR/scripts/get_run_cost.py" --since "$RUN_START_EPOCH" --scripts "run-linkedin-phaseA" "run-linkedin-phaseB" 2>/dev/null || echo "0.0000")
252
+ python3 "$REPO_DIR/scripts/log_run.py" --script post_linkedin --posted 0 --skipped 0 --failed 1 --cost "$_COST" --elapsed "$ELAPSED" || true
253
+ _SA_RUN_SUMMARY_EMITTED=1
254
+ echo "=== Run complete: $(date) ===" | tee -a "$LOG_FILE"
255
+ exit 0
256
+ fi
257
+
258
+ LI_PROJECT_NAME=$(printf '%s' "$PROJECT_PICK_JSON" | python3 -c "import json,sys; print(json.load(sys.stdin).get('name',''))")
259
+ LI_SEARCH_TOPIC=$(printf '%s' "$PROJECT_PICK_JSON" | python3 -c "import json,sys; print(json.load(sys.stdin).get('search_topic',''))")
260
+
261
+ if [ -z "$LI_PROJECT_NAME" ] || [ -z "$LI_SEARCH_TOPIC" ]; then
262
+ echo "Phase A: project/search_topic picker returned an incomplete assignment. Skipping LinkedIn run." | tee -a "$LOG_FILE"
263
+ ELAPSED=$(( $(date +%s) - RUN_START_EPOCH ))
264
+ _COST=$(python3 "$REPO_DIR/scripts/get_run_cost.py" --since "$RUN_START_EPOCH" --scripts "run-linkedin-phaseA" "run-linkedin-phaseB" 2>/dev/null || echo "0.0000")
265
+ python3 "$REPO_DIR/scripts/log_run.py" --script post_linkedin --posted 0 --skipped 0 --failed 1 --cost "$_COST" --elapsed "$ELAPSED" || true
266
+ _SA_RUN_SUMMARY_EMITTED=1
267
+ echo "=== Run complete: $(date) ===" | tee -a "$LOG_FILE"
268
+ exit 0
269
+ fi
270
+
271
+ echo "Phase A: picked project=$LI_PROJECT_NAME search_topic='$LI_SEARCH_TOPIC'" | tee -a "$LOG_FILE"
272
+
273
+ # Top-performing historical queries for this exact project/topic
274
+ # (positive signal, last 30d).
275
+ TOP_QUERIES=$(python3 "$REPO_DIR/scripts/top_linkedin_queries.py" --project "$LI_PROJECT_NAME" --search-topic "$LI_SEARCH_TOPIC" --limit 15 --window-days 30 2>/dev/null || echo "[]")
276
+
277
+ # Dud queries to AVOID redrafting for this exact project/topic
278
+ # (zero-result OR low-SERP, last 7d).
279
+ DUD_QUERIES=$(python3 "$REPO_DIR/scripts/top_dud_linkedin_queries.py" --project "$LI_PROJECT_NAME" --search-topic "$LI_SEARCH_TOPIC" --limit 30 --window-days 7 2>/dev/null || echo "[]")
280
+
281
+ # BSD mktemp on macOS only substitutes XXXXXX at the end of the template.
282
+ PHASE_A_OUT=$(mktemp /tmp/sa-run-linkedin-phaseA-XXXXXX)
283
+ PHASE_A_PROMPT=$(mktemp /tmp/sa-run-linkedin-phaseA-prompt-XXXXXX)
284
+
285
+ # --- DORMANT unipile branch: OFF by default (see header). Reached ONLY with an
286
+ # --- explicit LINKEDIN_BACKEND=unipile override, which still 503s until the
287
+ # --- UniPile account is manually reconnected. Presence here != in use.
288
+ if [ "$LINKEDIN_BACKEND" = "unipile" ]; then
289
+ # ----- Phase A prompt: UniPile REST backend (no browser) -----
290
+ cat > "$PHASE_A_PROMPT" <<PROMPT_EOF
291
+ You are the Social Autoposter LinkedIn discovery + scoring scout (Phase A),
292
+ running on the UniPile REST backend (no browser, no headed Chrome).
293
+
294
+ Your job: use the pre-selected project and assigned search_topic, draft 8
295
+ DYNAMIC LinkedIn search queries from that one topic, run each through the
296
+ UniPile search CLI, extract engagement metrics, rate SERP quality, pick the
297
+ single best candidate, write a structured JSON envelope to $PHASE_A_OUT, and
298
+ STOP. Do NOT draft a comment. Do NOT post anything. Phase B handles drafting
299
+ + posting using whatever you write to the candidates list.
300
+
301
+ ## Pre-selected project and assigned topic
302
+ $PROJECT_PICK_JSON
303
+
304
+ Assigned project: $LI_PROJECT_NAME
305
+ Assigned search_topic: $LI_SEARCH_TOPIC
306
+
307
+ ## Today's distribution (context only; the project is already picked)
308
+ $PROJECT_DIST
309
+
310
+ ## Top-performing historical queries for this project/topic
311
+ STYLE inspiration only - do NOT reuse them verbatim. LinkedIn SERPs shift
312
+ daily, so reusing exact phrasing is wasteful. Mine them for the angle/keyword
313
+ combo that worked, then craft something new.
314
+ $TOP_QUERIES
315
+
316
+ ## DUD queries to AVOID for this project/topic
317
+ Do NOT redraft any of these phrasings. They have been flat or audience-wrong
318
+ recently. 'zero_results' means LinkedIn rejected the keywords;
319
+ 'low_serp_quality' means results came back but were influencer slop /
320
+ off-target audience.
321
+ $DUD_QUERIES
322
+
323
+ ## Workflow
324
+
325
+ 1. Use ONLY this assigned project and search_topic. Do NOT pick another
326
+ project, do NOT switch topics, and do NOT iterate through the project list.
327
+
328
+ 2. Draft 8 search queries for the assigned topic. Each query should:
329
+ - Be 2-4 words (LinkedIn search hates long phrases)
330
+ - Target practitioners, not influencers (no "expert tips", "thought
331
+ leadership", or buzzwordy phrasing)
332
+ - Be FRESH - different from the dud list, different angle from the
333
+ top-performers list (steal the recipe, change the dish)
334
+ - Map directly to the assigned search_topic
335
+ - Cover DIFFERENT facets / pains / personas of the ICP - not 4 reskins
336
+ of the same query. Wider net = higher chance of one ICP-fit hit.
337
+
338
+ Run exactly 8 queries this run. More surface area beats narrow targeting:
339
+ most queries return slop, so the 2-3 that survive should reach you with
340
+ real candidates.
341
+
342
+ 3. For EACH query, shell out via the Bash tool (ONE line, no browser):
343
+
344
+ python3 $REPO_DIR/scripts/linkedin_unipile.py search --keywords "<query>" --date-posted past_week --sort-by date --with-followers --pipeline --limit 8
345
+
346
+ This calls the UniPile REST API (a server-hosted LinkedIn session on the
347
+ same account; there is NO local browser to prime or navigate) and prints a
348
+ JSON envelope to stdout:
349
+
350
+ {
351
+ "ok": true,
352
+ "query": "<query>",
353
+ "result_count": N,
354
+ "cursor": "...|null",
355
+ "results": [
356
+ {
357
+ "post_url": "https://www.linkedin.com/feed/update/urn:li:<ns>:<num>/",
358
+ "activity_id": "<num>",
359
+ "all_urns": ["<num>"],
360
+ "social_id": "urn:li:<ns>:<num>",
361
+ "author_name": "...",
362
+ "author_headline": "...|null",
363
+ "author_profile_url": "https://www.linkedin.com/in/<slug>/|null",
364
+ "author_followers": <int|null>,
365
+ "post_text": "...",
366
+ "age_hours": <float|null>,
367
+ "reactions": <int>,
368
+ "comments": <int>,
369
+ "reposts": <int>,
370
+ "is_repost": <bool>
371
+ }, ...
372
+ ]
373
+ }
374
+
375
+ UniPile returns the post URN directly in social_id / post_url /
376
+ activity_id, with the CORRECT namespace (activity / share / ugcPost). There
377
+ is NO click-to-resolve step and NO URN-namespace guessing — copy these
378
+ fields through verbatim.
379
+
380
+ Failure handling: if a query prints "ok": false, or an object with an
381
+ "error" / error "response" field (HTTP 401 / 429 / 5xx), treat it like a
382
+ zero-result query — record it in queries_used with candidates_found=0 and
383
+ serp_quality_score=null, then continue to the next query. If the VERY FIRST
384
+ query returns an auth error (HTTP 401 missing_credentials), the UniPile
385
+ session is dead: write the envelope with whatever queries_used you have and
386
+ candidates: [], then STOP.
387
+
388
+ 3a. RATE THE SERP QUALITY 0-10 for THIS query, based on:
389
+ - Practitioner ratio: judge from author_headline AND author_followers
390
+ (low-follower / hands-on builders > influencer-tier accounts).
391
+ - Topic fit: do the post_text excerpts actually match the project domain?
392
+ - Freshness: median age_hours of results (lower = better).
393
+ - 0-3 = useless slop, 4-5 = mixed, 6-8 = mostly relevant, 9-10 = goldmine.
394
+
395
+ 3b. SKIP candidates authored by Matthew Diakonov / linkedin.com/in/m13v/.
396
+
397
+ 3c. Dedup against engaged history. Gather the activity_id of every
398
+ candidate across all queries into one comma-separated list, then run
399
+ ONCE via Bash:
400
+ python3 $REPO_DIR/scripts/linkedin_url.py --check-engaged-ids 'id1,id2,id3'
401
+ Exit code 0 means at least one is already engaged; use the script's
402
+ output to drop any candidate whose activity_id is already engaged.
403
+
404
+ 4. PICK THE SINGLE BEST CANDIDATE across all queries.
405
+ - The UniPile results are NOT pre-scored. Weigh engagement
406
+ (reactions + 2*comments + 3*reposts) against age_hours yourself: a post
407
+ with 40 reactions in 3 hours beats 60 reactions in 5 days. Favor recent
408
+ posts with real, non-trivial engagement.
409
+
410
+ - LEAN TOWARD POSTING. The bar is: "would commenting here be embarrassing
411
+ or off-message for the project?" NOT "is this a perfect ICP fit?"
412
+ A mediocre but on-topic comment costs around twenty cents; a missed real
413
+ fit costs the entire cycle (roughly fifteen dollars). Favor the post.
414
+
415
+ - HARD-REJECT (these are the only auto-disqualifiers):
416
+ 1. Direct competitor: the author or their company sells a product that
417
+ competes with the project. Name the competing product in your
418
+ rationale. Vague competitor vibes are NOT enough.
419
+ 2. Recruiter / job-ad post: body is "we're hiring", "open role", a job
420
+ description, or a careers-page link.
421
+ 3. Off-topic content: politics, personal milestones, unrelated
422
+ industry, news commentary not tied to the project's domain.
423
+ 4. Author is m13v / Matthew Diakonov. (Already filtered earlier.)
424
+
425
+ - SOFT SIGNALS (do NOT auto-reject on these alone):
426
+ * Author on a brand/company page (author_profile_url null but
427
+ author_name present): engageable IF the post topic is on-message.
428
+ * Adjacent persona / not the perfect ICP buyer: fine if the topic
429
+ resonates with the project's wedge.
430
+ * Lower follower count / "no-name" author: irrelevant to whether we
431
+ should comment; practitioners with smaller audiences are often
432
+ higher-quality targets than influencers.
433
+ * Some buzzwords / hype framing: tolerable if the underlying post-topic
434
+ is a real practitioner pain.
435
+
436
+ - NAME THE VERDICT EXPLICITLY in your rationale: which hard-reject category
437
+ fired (1/2/3/4), or "soft fit, posting." Do not write "ICP mismatch"
438
+ without naming which category.
439
+
440
+ - One winner. Not a ranked list. Not a top-3.
441
+
442
+ 5. Write the envelope to $PHASE_A_OUT with the winner (and ONLY the winner —
443
+ discard runners-up, they are noise that will not be reused) and STOP:
444
+
445
+ \`\`\`bash
446
+ cat > $PHASE_A_OUT <<JSON_EOF
447
+ {
448
+ "project": "$LI_PROJECT_NAME",
449
+ "search_topic": "$LI_SEARCH_TOPIC",
450
+ "language": "en",
451
+ "queries_used": [
452
+ {"query": "ai agents production", "search_topic": "$LI_SEARCH_TOPIC", "candidates_found": 4, "serp_quality_score": 7.5, "dropped_below_floor": 0},
453
+ {"query": "macos automation tools", "search_topic": "$LI_SEARCH_TOPIC", "candidates_found": 0, "serp_quality_score": null, "dropped_below_floor": 0},
454
+ {"query": "claude code workflow", "search_topic": "$LI_SEARCH_TOPIC", "candidates_found": 6, "serp_quality_score": 5.0, "dropped_below_floor": 0}
455
+ ],
456
+ "candidates": [
457
+ {
458
+ "post_url": "https://www.linkedin.com/feed/update/urn:li:activity:NUMERIC/",
459
+ "activity_id": "NUMERIC",
460
+ "all_urns": ["NUMERIC"],
461
+ "author_name": "First Last",
462
+ "author_headline": "Headline | role | company (may be null)",
463
+ "author_profile_url": "https://www.linkedin.com/in/SLUG/",
464
+ "author_followers": 2124,
465
+ "post_text": "post body, no newlines, no double quotes, no backticks",
466
+ "age_hours": 6.5,
467
+ "reactions": 42,
468
+ "comments": 7,
469
+ "reposts": 3,
470
+ "search_topic": "$LI_SEARCH_TOPIC",
471
+ "search_query": "ai agents production",
472
+ "language": "en",
473
+ "serp_quality_score": 7.5
474
+ }
475
+ ]
476
+ }
477
+ JSON_EOF
478
+ \`\`\`
479
+
480
+ - queries_used MUST contain ONE row per query you ran (including
481
+ zero-result ones — that is the whole point of the dud-learning).
482
+ - project MUST equal "$LI_PROJECT_NAME" and every search_topic MUST equal
483
+ "$LI_SEARCH_TOPIC". The search_query is the literal phrase you ran.
484
+ - candidates_found is the count of usable candidates that query surfaced
485
+ (after dropping self-authored / already-engaged). dropped_below_floor
486
+ is always 0: neither path applies a virality floor (Twitter model).
487
+ - candidates contains AT MOST one row (the winner from step 4). It can be
488
+ empty if step 4 found nothing engageable. bash skips Phase B cleanly
489
+ when empty.
490
+ - The winner row MUST copy post_url, activity_id, and author_followers
491
+ VERBATIM from the chosen search result. Do NOT rebuild or rewrite the URN
492
+ namespace — UniPile already returned the correct one. Do NOT null out
493
+ author_followers; it is a real number on this path and the scorer uses it.
494
+ - candidates must NOT include posts you already engaged on or self-authored.
495
+ - author_headline is optional on output; pass through whatever the search
496
+ returned (may be null).
497
+ - post_text must be safe to embed in a bash double-quoted string. Strip
498
+ backticks, double quotes, and newlines before writing. Truncate to ~500
499
+ chars before writing into the envelope.
500
+
501
+ Then say '## Phase A: envelope written' and STOP.
502
+
503
+ CRITICAL: Use ONLY the Bash tool plus the linkedin_unipile.py / linkedin_url.py
504
+ scripts. There is NO browser in this path — NEVER attempt any browser MCP
505
+ tools (none are loaded) and never try to navigate a webpage.
506
+ CRITICAL: Run exactly 8 search queries this run. Not 2, not 4, not 6. Eight.
507
+ CRITICAL: NEVER use em dashes anywhere.
508
+ PROMPT_EOF
509
+ else
510
+ # ----- Phase A prompt: headed-Chrome browser backend (linkedin-harness) -----
511
+ cat > "$PHASE_A_PROMPT" <<PROMPT_EOF
512
+ You are the Social Autoposter LinkedIn discovery + scoring scout (Phase A).
513
+
514
+ $BROWSER_INSTRUCTIONS
515
+
516
+ Your job: use the pre-selected project and assigned search_topic, draft 8
517
+ DYNAMIC LinkedIn search queries from that one topic, browse each query's
518
+ LinkedIn SERP, extract engagement metrics for every visible candidate post,
519
+ write a structured JSON envelope to $PHASE_A_OUT, and STOP. Do NOT draft a
520
+ comment. Do NOT post anything. Phase B handles drafting + posting using
521
+ whatever you write to the candidates list.
522
+
523
+ ## Pre-selected project and assigned topic
524
+ $PROJECT_PICK_JSON
525
+
526
+ Assigned project: $LI_PROJECT_NAME
527
+ Assigned search_topic: $LI_SEARCH_TOPIC
528
+
529
+ ## Today's distribution (context only; the project is already picked)
530
+ $PROJECT_DIST
531
+
532
+ ## Top-performing historical queries for this project/topic
533
+ These are STYLE inspiration only - do NOT reuse them verbatim. LinkedIn
534
+ SERPs shift daily, so reusing the exact same phrasing is wasteful. Mine
535
+ them for the angle/keyword combo that worked, then craft something new.
536
+ $TOP_QUERIES
537
+
538
+ ## DUD queries to AVOID for this project/topic
539
+ Do NOT redraft any of these phrasings. They have been flat or
540
+ audience-wrong recently. Note the 'reason' field - 'zero_results' means
541
+ LinkedIn rejected the keywords; 'low_serp_quality' means results came
542
+ back but were influencer slop / off-target audience.
543
+ $DUD_QUERIES
544
+
545
+ ## Workflow
546
+
547
+ 1. Use ONLY this assigned project and search_topic. Do NOT pick another
548
+ project, do NOT switch topics, and do NOT iterate through the project list.
549
+
550
+ 2. Draft 8 search queries for the assigned topic. Each query should:
551
+ - Be 2-4 words (LinkedIn search hates long phrases)
552
+ - Target practitioners, not influencers (no "expert tips", "thought
553
+ leadership", or buzzwordy phrasing)
554
+ - Be FRESH - different from the dud list, different angle from the
555
+ top-performers list (steal the recipe, change the dish)
556
+ - Map directly to the assigned search_topic
557
+ - Cover DIFFERENT facets / pains / personas of the ICP - not 4 reskins
558
+ of the same query. Wider net = higher chance of one ICP-fit hit.
559
+
560
+ Run 8 queries this run. More surface area beats narrow targeting:
561
+ most queries will return slop and get retired into the dud list, so the
562
+ 2-3 that survive should reach the LLM with real candidates. The
563
+ LinkedIn rate budget (40/24h, 150/30d) accommodates this fine; rate
564
+ caps are not the bottleneck, candidate quality is.
565
+
566
+ 3. PRIME the harness browser ONCE before the per-query loop. This confirms
567
+ the harness Chrome is up and the session is alive before the discover
568
+ script CDP-attaches to it.
569
+ 3pre. Navigate (per the BROWSER BACKEND block) to https://www.linkedin.com/
570
+ (one navigation), then take a screenshot and Read it.
571
+ 3pre-check. If the resulting URL contains /uas/login or /checkpoint/, or the
572
+ screenshot shows a login / captcha / verify-you-are-human page, the
573
+ persistent session is dead. Print SESSION_INVALID, write an empty
574
+ envelope (no queries_used, no candidates) and STOP. The user must
575
+ re-auth the harness LinkedIn Chrome interactively before the next run.
576
+
577
+ 4. For EACH query, shell out via the Bash tool:
578
+
579
+ SOCIAL_AUTOPOSTER_LINKEDIN_SEARCH=1 $LINKEDIN_DISCOVER_PYTHON \\
580
+ $REPO_DIR/scripts/discover_linkedin_candidates.py content "<query>"
581
+
582
+ The script CDP-attaches to the SAME harness Chrome (LINKEDIN_CDP_URL is
583
+ already exported to the harness port; same cookies/session/fingerprint, no
584
+ second browser), navigates the SERP, extracts every visible card, and prints
585
+ a JSON envelope to stdout. Do NOT drive the browser yourself for discovery —
586
+ the script handles navigation and extraction.
587
+
588
+ Result shape on success:
589
+
590
+ {
591
+ "ok": true,
592
+ "url": "https://www.linkedin.com/search/results/content/?keywords=...",
593
+ "vertical": "content",
594
+ "query": "<query>",
595
+ "result_count": N,
596
+ "dropped_below_virality_floor": 0,
597
+ "virality_floor": null,
598
+ "results": [ // SORTED by velocity_score DESC, top of list = highest score
599
+ {
600
+ "post_url": "...|null",
601
+ "activity_id": "...|null",
602
+ "all_urns": [],
603
+ "author_name": "...",
604
+ "author_headline": "...|null",
605
+ "author_profile_url": "...",
606
+ "author_followers": null,
607
+ "post_text": "...",
608
+ "age_hours": <float>,
609
+ "age_text": "5m",
610
+ "reactions": <int>,
611
+ "comments": <int>,
612
+ "reposts": <int>
613
+ }, ...
614
+ ],
615
+ "rate_budget": {
616
+ "daily_used": N, "daily_cap": 40,
617
+ "monthly_used": N, "monthly_cap": 150
618
+ }
619
+ }
620
+
621
+ result_count is ALL cards the SERP returned (Twitter model: no virality
622
+ floor, nothing is dropped on score). The cards are scored and sorted by
623
+ velocity_score DESC so the strongest engagement signal sits at the top,
624
+ but weak cards stay in the list as fallback. dropped_below_virality_floor
625
+ is always 0 now; copy it into queries_used as dropped_below_floor=0. The
626
+ dashboard reads raw SERP volume straight off candidates_found, so a query
627
+ that returns 0 cards still reads as "SERP returned nothing".
628
+
629
+ New SDUI caveat: post_url and activity_id are null for posts that don't
630
+ embed a quoted/reposted share. That's expected — KEEP these in your
631
+ working set, judge them on author/headline/post_text/age/engagement,
632
+ and let step 5 below resolve the URN by clicking into the chosen winner.
633
+
634
+ Failure handling (the JSON's "error" field):
635
+ - "rate_limited" → sleep retry_after_seconds, retry once. If still
636
+ rate-limited after retry, skip this query and
637
+ continue to the next.
638
+ - "serp_redirected" → log this query in queries_used with
639
+ candidates_found=0, serp_quality_score=0;
640
+ skip and move to next query.
641
+ - "session_invalid" → write empty envelope and STOP. Phase B will skip.
642
+ - "mcp_not_running" → same as session_invalid.
643
+ - "navigation_failed" → skip this query, continue.
644
+ - "db_unavailable" → script already fails closed; treat like
645
+ "rate_limited" with no retry budget visible.
646
+ On any non-ok, still append to queries_used so the run is auditable.
647
+
648
+ 4a. RATE THE SERP QUALITY 0-10 for THIS query, based on:
649
+ - Practitioner ratio: judge from author_headline and post_text
650
+ (low-follower / hands-on builders > influencer-tier accounts).
651
+ author_followers is null on the new SDUI layout, so headline tone
652
+ is your primary signal.
653
+ - Topic fit: do the post excerpts actually match the project's domain?
654
+ - Freshness: median age_hours of results (lower = better)
655
+ - 0-3 = useless slop, 4-5 = mixed, 6-8 = mostly relevant, 9-10 = goldmine
656
+ Write the score into the queries_used record (see envelope below).
657
+
658
+ 4b. SKIP candidates authored by Matthew Diakonov / linkedin.com/in/m13v/.
659
+
660
+ 4c. SKIP candidates that already have a known URN AND are already
661
+ engaged. Run:
662
+ python3 $REPO_DIR/scripts/linkedin_url.py --check-engaged-ids 'comma,sep,urns'
663
+ For each candidate that HAS a non-null activity_id (the embedded-
664
+ quoted-share case), check its all_urns set; if ANY URN already
665
+ engaged, drop the candidate. Candidates with activity_id == null
666
+ skip this check (their URN isn't known yet) — step 5 will resolve
667
+ the URN before the engaged-id check runs again at Phase B.
668
+
669
+ 5. PICK THE SINGLE BEST CANDIDATE across all queries.
670
+ - Within each query's "results" array, candidates are PRE-SORTED by
671
+ velocity_score DESCENDING (top of list = strongest engagement signal).
672
+ Default to candidates near the top — the score already encodes
673
+ reactions/comments/reposts/age, so the top of each list is a real
674
+ prior. Walking past the top-3 of any query should require a clear
675
+ ICP-fit reason. Do not skip a #1 just because #4 looks "interesting".
676
+
677
+ - LEAN TOWARD POSTING. The bar is: "would commenting here be embarrassing
678
+ or off-message for the project?" NOT "is this a perfect ICP fit?"
679
+ A mediocre but on-topic comment costs around twenty cents; a missed
680
+ real fit costs the entire cycle (roughly fifteen dollars). The cost is
681
+ asymmetric, so favor the post.
682
+
683
+ - HARD-REJECT (these are the only auto-disqualifiers):
684
+ 1. Direct competitor: the author or their company sells a product
685
+ that competes with the project. Name the competing product in
686
+ your rationale ("logistify.ai builds the same RPA-replacement
687
+ agent Mediar does"). Vague competitor vibes are NOT enough.
688
+ 2. Recruiter / job-ad post: post body is "we're hiring", "open
689
+ role", a job description, or a careers-page link. Engaging
690
+ drops us into a recruiting funnel, off-message.
691
+ 3. Off-topic content: politics, personal milestones (weddings,
692
+ baby announcements), unrelated industry, news commentary not
693
+ tied to the project's domain.
694
+ 4. Author is m13v / Matthew Diakonov. (Already filtered earlier.)
695
+
696
+ - SOFT SIGNALS (do NOT auto-reject on these alone):
697
+ * Author is on a brand/company page (author_profile_url null but
698
+ author_name present): engageable IF the post topic is on-message
699
+ for the project. Brand-page comments still get seen.
700
+ * Adjacent persona / not the perfect ICP buyer: a freelance dev
701
+ posting about ops automation is adjacent to Mediar's enterprise-
702
+ ops ICP, not on it. Adjacent is fine if the topic resonates with
703
+ the project's wedge — adjacent personas often spread the message
704
+ to actual buyers.
705
+ * Lower follower count / "no-name" author: irrelevant to whether
706
+ we should comment. Practitioners with smaller audiences are
707
+ often higher-quality engagement targets than influencers.
708
+ * Some buzzwords / hype framing: tolerable if the underlying
709
+ post-topic is a real practitioner pain.
710
+
711
+ - NAME THE VERDICT EXPLICITLY in your rationale: which hard-reject
712
+ category fired (1/2/3/4), or "soft fit, posting." Do not write
713
+ "ICP mismatch" without naming which category.
714
+
715
+ - One winner. Not a ranked list. Not a top-3.
716
+ - If the winner already has a non-null activity_id (rare: only the
717
+ embedded-share case), skip step 5a/5b/5c — go straight to step 6.
718
+
719
+ 5a. The winner's SERP card has a clickable timestamp / "Feed post"
720
+ title link that opens the canonical post detail. Click it ONCE
721
+ (per the BROWSER BACKEND block: locate the matching card via
722
+ getBoundingClientRect, then click_at_xy on its timestamp/title link).
723
+ (Use the post_text first ~60 chars to disambiguate which card
724
+ on the SERP is the winner.) Click on exactly one card per run.
725
+
726
+ 5b. After the navigation settles, read the resulting page URL via
727
+ the BROWSER BACKEND block's run-code equivalent (bh_run js("""return location.href""")).
728
+ Match /urn:li:(activity|share|ugcPost):(\\d{16,19})/ — capture
729
+ BOTH the URN type (activity / share / ugcPost) and the numeric.
730
+
731
+ CRITICAL: activity / share / ugcPost URNs are DIFFERENT namespaces.
732
+ The same numeric ID resolves to different posts (or to nothing) in
733
+ different namespaces. You MUST preserve the type when building the
734
+ canonical URL — never collapse share/ugcPost to activity.
735
+
736
+ post_url = https://www.linkedin.com/feed/update/urn:li:<TYPE>:<NUM>/
737
+ activity_id = <NUM> (bare numeric, for engaged-id check)
738
+
739
+ If your click in 5a did NOT navigate (page still shows the SERP
740
+ URL), fall back to the 3-dot menu → "Copy link to post" route
741
+ (all clicks via click_at_xy per the BROWSER BACKEND block):
742
+ - click the 3-dot control menu of the winner card
743
+ - click the "Copy link to post" menu item
744
+ - read the URL from clipboard via the run-code equivalent
745
+ (bh_run js("""return await navigator.clipboard.readText()""")) (may fail
746
+ with permission denied in headed Chrome — try Bash 'pbpaste' as a backup)
747
+ - the slug encodes the URN type: parse /-(activity|share|ugcPost)-(\\d{16,19})/
748
+ from the URL. Build canonical exactly as above using the captured TYPE.
749
+ - Example: https://www.linkedin.com/posts/SLUG-share-7455...-pkG-...
750
+ → urn_type = "share", activity_id = "7455...",
751
+ post_url = https://www.linkedin.com/feed/update/urn:li:share:7455.../
752
+
753
+ 5c. If neither 5a nor the copy-link fallback yields a URN, drop this
754
+ winner from your candidates list and pick the NEXT best one. Retry
755
+ 5a once on the second-best. If that also fails, write candidates: []
756
+ and STOP — Phase B will skip cleanly. Do NOT loop through every
757
+ candidate trying to resolve URNs.
758
+
759
+ 5d. Re-run the engaged-id check on the now-known numeric:
760
+ python3 $REPO_DIR/scripts/linkedin_url.py --check-engaged-ids 'NUM'
761
+ Exit 0 = already engaged, candidates: [], STOP.
762
+
763
+ 6. Write the envelope to $PHASE_A_OUT with the winner (and ONLY the
764
+ winner — discard runners-up, they're noise that won't be reused) and
765
+ STOP:
766
+
767
+ \`\`\`bash
768
+ cat > $PHASE_A_OUT <<JSON_EOF
769
+ {
770
+ "project": "$LI_PROJECT_NAME",
771
+ "search_topic": "$LI_SEARCH_TOPIC",
772
+ "language": "en",
773
+ "queries_used": [
774
+ {"query": "ai agents production", "search_topic": "$LI_SEARCH_TOPIC", "candidates_found": 4, "serp_quality_score": 7.5, "dropped_below_floor": 2},
775
+ {"query": "macos automation tools", "search_topic": "$LI_SEARCH_TOPIC", "candidates_found": 0, "serp_quality_score": null, "dropped_below_floor": 0},
776
+ {"query": "claude code workflow", "search_topic": "$LI_SEARCH_TOPIC", "candidates_found": 6, "serp_quality_score": 5.0, "dropped_below_floor": 9}
777
+ ],
778
+ "candidates": [
779
+ {
780
+ "post_url": "https://www.linkedin.com/feed/update/urn:li:activity:NUMERIC/",
781
+ "activity_id": "NUMERIC",
782
+ "all_urns": ["NUMERIC", "..."],
783
+ "author_name": "First Last",
784
+ "author_headline": "Headline | role | company (may be null)",
785
+ "author_profile_url": "https://www.linkedin.com/in/SLUG/",
786
+ "author_followers": null,
787
+ "post_text": "post body, no newlines, no double quotes, no backticks",
788
+ "age_hours": 6.5,
789
+ "reactions": 42,
790
+ "comments": 7,
791
+ "reposts": 3,
792
+ "search_topic": "$LI_SEARCH_TOPIC",
793
+ "search_query": "ai agents production",
794
+ "language": "en",
795
+ "serp_quality_score": 7.5
796
+ }
797
+ ]
798
+ }
799
+ JSON_EOF
800
+ \`\`\`
801
+
802
+ - queries_used MUST contain ONE row per query you ran (including
803
+ zero-result ones — that is the whole point of the dud-learning).
804
+ - project MUST equal "$LI_PROJECT_NAME" and every search_topic MUST
805
+ equal "$LI_SEARCH_TOPIC". The search_topic is the assigned seed; the
806
+ search_query is the literal phrase you ran on LinkedIn.
807
+ - candidates_found is ALL cards the SERP returned, same as the discover
808
+ script's result_count (Twitter model: no virality floor, nothing is
809
+ dropped on score; cards are sorted by velocity_score DESC). Set
810
+ dropped_below_floor to 0 for every query: the discover script no longer
811
+ rejects cards, so its dropped_below_virality_floor is always 0. The
812
+ dashboard reads raw SERP volume straight off candidates_found, so a
813
+ query with candidates_found=0 still reads as "SERP returned nothing".
814
+ - candidates contains AT MOST one row (the winner from step 5). It can
815
+ be empty if step 5 found nothing engageable. bash will skip Phase B
816
+ cleanly when empty.
817
+ - The winner row MUST have non-null activity_id and post_url (resolved
818
+ at step 5b). Do NOT write null URNs to candidates[] — Phase B no
819
+ longer recovers them.
820
+ - post_url MUST embed the correct URN namespace
821
+ (urn:li:activity:NUM, urn:li:share:NUM, or urn:li:ugcPost:NUM) — NOT
822
+ forcibly rewritten to activity. The shell trusts this URL verbatim.
823
+ - candidates must NOT include posts you already engaged on or self-authored.
824
+ - author_headline is optional on output; pass through whatever the
825
+ discover script returned (may be null).
826
+ - author_followers is null on the current LinkedIn layout; do not invent
827
+ a value.
828
+ - post_text must be safe to embed in a bash double-quoted string. Strip
829
+ backticks, double quotes, and newlines before writing. Truncate to
830
+ ~500 chars before writing into the envelope to keep Phase B's prompt
831
+ compact (the full text is still available via the discover script log).
832
+
833
+ Then say '## Phase A: envelope written' and STOP.
834
+
835
+ CRITICAL: Use ONLY the browser tool described in the BROWSER BACKEND block
836
+ (mcp__linkedin-harness__bh_run). NEVER click the comment textbox. NEVER call
837
+ createComment. NEVER navigate to a post-compose flow. Phase B does all of that.
838
+ CRITICAL: Run exactly 8 search queries this run. Not 2, not 4, not 6. Eight.
839
+ Wider net = better odds of one ICP-fit hit. The rate budget can absorb it.
840
+ CRITICAL: NEVER use em dashes anywhere.
841
+ PROMPT_EOF
842
+ fi
843
+
844
+ # --- DORMANT unipile branch: OFF by default (see header). Reached ONLY with an
845
+ # --- explicit LINKEDIN_BACKEND=unipile override, which still 503s until the
846
+ # --- UniPile account is manually reconnected. Presence here != in use.
847
+ if [ "$LINKEDIN_BACKEND" = "unipile" ]; then
848
+ # UniPile path: no headed browser, so no linkedin-browser lock, no
849
+ # ensure_browser_healthy, no harness MCP, no PreToolUse hook lockfile.
850
+ # --strict-mcp-config with NO --mcp-config loads zero MCP servers, leaving
851
+ # the default Bash tool the agent uses to shell out to linkedin_unipile.py.
852
+ set +e
853
+ "$REPO_DIR/scripts/run_claude.sh" "run-linkedin-phaseA" --strict-mcp-config --output-format stream-json --verbose -p "$(cat "$PHASE_A_PROMPT")" 2>&1 | tee -a "$LOG_FILE"
854
+ PA_RC=${PIPESTATUS[0]}
855
+ set -e
856
+ rm -f "$PHASE_A_PROMPT"
857
+ else
858
+ # Acquire linkedin-browser ONLY for the Phase A Claude run. The shell lock
859
+ # (skill/lock.sh) is FIFO-queued, so if a peer pipeline (dm-replies-linkedin,
860
+ # audit-linkedin, link-edit-linkedin, or our own prior cycle's Phase B) is
861
+ # mid-run, this BLOCKS and polls until release rather than skipping. That
862
+ # matches the run-twitter-cycle.sh + run-reddit-search.sh behaviour.
863
+ #
864
+ # run_claude.sh auto-exports SA_PIPELINE_LOCKED=1 + SA_PIPELINE_PLATFORM,
865
+ # which the PreToolUse hook (~/.claude/hooks/linkedin-agent-lock.sh) honors
866
+ # to skip the cross-session block check. Without that bypass, the hook
867
+ # previously rejected our Claude session if the prior cycle's JSONL was
868
+ # <60s stale (tail-flush window), producing $8.91 empty-envelope runs.
869
+ # 2026-05-01: false-positive hardened by env-var bypass + pgrep alive check.
870
+ acquire_lock "linkedin-browser" 3600
871
+ ensure_linkedin_browser_for_backend 2>&1 | tee -a "$LOG_FILE"
872
+
873
+ set +e
874
+ "$REPO_DIR/scripts/run_claude.sh" "run-linkedin-phaseA" --strict-mcp-config --mcp-config "$MCP_CONFIG_FILE" --output-format stream-json --verbose -p "$(cat "$PHASE_A_PROMPT")" 2>&1 | tee -a "$LOG_FILE"
875
+ PA_RC=${PIPESTATUS[0]}
876
+ set -e
877
+
878
+ release_lock "linkedin-browser"
879
+ # Defense-in-depth: explicitly clear the hook-layer lockfile so the next
880
+ # pipeline cycle's PreToolUse never sees a stale entry from us. The
881
+ # run_claude.sh exit trap already does this in the happy path; this
882
+ # repeat is harmless and covers SIGKILL of run_claude.sh.
883
+ rm -f "$HOME/.claude/linkedin-agent-lock.json"
884
+ rm -f "$PHASE_A_PROMPT"
885
+ fi
886
+
887
+ # ===== Validate Phase A envelope + run Python ingest steps =====
888
+ if [ "$PA_RC" -ne 0 ] || [ ! -s "$PHASE_A_OUT" ]; then
889
+ echo "Phase A: no envelope (rc=$PA_RC, $([ -s "$PHASE_A_OUT" ] && echo 'file non-empty' || echo 'file empty')). Skipping Phase B." | tee -a "$LOG_FILE"
890
+ rm -f "$PHASE_A_OUT"
891
+ ELAPSED=$(( $(date +%s) - RUN_START_EPOCH ))
892
+ _COST=$(python3 "$REPO_DIR/scripts/get_run_cost.py" --since "$RUN_START_EPOCH" --scripts "run-linkedin-phaseA" "run-linkedin-phaseB" 2>/dev/null || echo "0.0000")
893
+ python3 "$REPO_DIR/scripts/log_run.py" --script post_linkedin --posted 0 --skipped 1 --failed 0 --cost "$_COST" --elapsed "$ELAPSED" || true
894
+ _SA_RUN_SUMMARY_EMITTED=1 # short-circuit EXIT-trap emitter; this branch already wrote a tailored line
895
+ echo "=== Run complete: $(date) ===" | tee -a "$LOG_FILE"
896
+ find "$LOG_DIR" -name "run-linkedin-*.log" -mtime +7 -delete 2>/dev/null || true
897
+ exit 0
898
+ fi
899
+
900
+ # Validate the envelope is well-formed JSON; if it isn't, ledger the run
901
+ # as failed and skip Phase B rather than crashing the ingest scripts.
902
+ if ! python3 -c "import json,sys; json.load(open('$PHASE_A_OUT'))" 2>/dev/null; then
903
+ echo "Phase A: envelope is malformed JSON; skipping Phase B." | tee -a "$LOG_FILE"
904
+ rm -f "$PHASE_A_OUT"
905
+ ELAPSED=$(( $(date +%s) - RUN_START_EPOCH ))
906
+ _COST=$(python3 "$REPO_DIR/scripts/get_run_cost.py" --since "$RUN_START_EPOCH" --scripts "run-linkedin-phaseA" "run-linkedin-phaseB" 2>/dev/null || echo "0.0000")
907
+ python3 "$REPO_DIR/scripts/log_run.py" --script post_linkedin --posted 0 --skipped 0 --failed 1 --cost "$_COST" --elapsed "$ELAPSED" || true
908
+ _SA_RUN_SUMMARY_EMITTED=1
909
+ echo "=== Run complete: $(date) ===" | tee -a "$LOG_FILE"
910
+ exit 0
911
+ fi
912
+
913
+ PA_PROJECT=$(python3 -c "import json; print(json.load(open('$PHASE_A_OUT')).get('project',''))" 2>/dev/null || echo "")
914
+ PA_PROJECT="$LI_PROJECT_NAME"
915
+ PA_SEARCH_TOPIC=$(python3 -c "import json; print(json.load(open('$PHASE_A_OUT')).get('search_topic',''))" 2>/dev/null || echo "")
916
+ PA_SEARCH_TOPIC="$LI_SEARCH_TOPIC"
917
+
918
+ # Ingest queries_used into linkedin_search_attempts (one row per query, dud-aware).
919
+ LI_PROJECT_NAME="$LI_PROJECT_NAME" LI_SEARCH_TOPIC="$LI_SEARCH_TOPIC" python3 -c "
920
+ import os
921
+ import json
922
+ env = json.load(open('$PHASE_A_OUT'))
923
+ project = os.environ.get('LI_PROJECT_NAME') or env.get('project','')
924
+ search_topic = os.environ.get('LI_SEARCH_TOPIC') or env.get('search_topic','')
925
+ out = []
926
+ for q in env.get('queries_used') or []:
927
+ out.append({
928
+ 'query': q.get('query',''),
929
+ 'project': project,
930
+ 'search_topic': search_topic,
931
+ 'candidates_found': q.get('candidates_found') or 0,
932
+ 'serp_quality_score': q.get('serp_quality_score'),
933
+ 'dropped_below_floor': q.get('dropped_below_floor') or 0,
934
+ })
935
+ import sys; json.dump(out, sys.stdout)
936
+ " | python3 "$REPO_DIR/scripts/log_linkedin_search_attempts.py" --batch-id "$BATCH_ID" 2>&1 | tee -a "$LOG_FILE" || true
937
+
938
+ # Ingest candidates into linkedin_candidates (scored + deduped).
939
+ # Stamp serp_quality_score onto each candidate from its parent query so the
940
+ # scoring upsert has the per-row signal even though SERP quality is judged
941
+ # per-query.
942
+ LI_PROJECT_NAME="$LI_PROJECT_NAME" LI_SEARCH_TOPIC="$LI_SEARCH_TOPIC" python3 -c "
943
+ import os
944
+ import json
945
+ env = json.load(open('$PHASE_A_OUT'))
946
+ quality_by_query = {q.get('query',''): q.get('serp_quality_score') for q in env.get('queries_used') or []}
947
+ project = os.environ.get('LI_PROJECT_NAME') or env.get('project','')
948
+ search_topic = os.environ.get('LI_SEARCH_TOPIC') or env.get('search_topic','')
949
+ lang = env.get('language','en')
950
+ cands = []
951
+ for c in env.get('candidates') or []:
952
+ if not isinstance(c, dict):
953
+ continue
954
+ c['matched_project'] = project
955
+ c['search_topic'] = search_topic
956
+ c.setdefault('language', lang)
957
+ if c.get('serp_quality_score') is None:
958
+ c['serp_quality_score'] = quality_by_query.get(c.get('search_query',''))
959
+ cands.append(c)
960
+ import sys; json.dump(cands, sys.stdout)
961
+ " | python3 "$REPO_DIR/scripts/score_linkedin_candidates.py" --batch-id "$BATCH_ID" 2>&1 | tee -a "$LOG_FILE" || true
962
+
963
+ # ===== Pick top pending candidate from this batch (or fallback to global pending) =====
964
+ # We try the freshest batch first so a high-velocity post we just discovered
965
+ # wins over an older pending row that didn't get posted last cycle. If the
966
+ # fresh batch has zero usable rows (everything we saw was already engaged),
967
+ # fall back only inside the same pre-picked project/topic. A cycle should
968
+ # never post an older candidate from some other project just because the
969
+ # fresh search returned nothing.
970
+ PA_PICK=$(REPO_DIR="$REPO_DIR" BATCH_ID="$BATCH_ID" LI_PROJECT_NAME="$LI_PROJECT_NAME" LI_SEARCH_TOPIC="$LI_SEARCH_TOPIC" python3 - <<'PY' 2>/dev/null || echo "{}"
971
+ import json
972
+ import os
973
+ import sys
974
+
975
+ repo = os.environ["REPO_DIR"]
976
+ batch_id = os.environ["BATCH_ID"]
977
+ project = os.environ.get("LI_PROJECT_NAME", "")
978
+ search_topic = os.environ.get("LI_SEARCH_TOPIC", "")
979
+
980
+ sys.path.insert(0, os.path.join(repo, "scripts"))
981
+ from http_api import api_get
982
+
983
+ # Two-stage pending pick (freshest batch first, then same-project/topic
984
+ # fallback within a 96h window) runs server-side; see route.ts. The returned
985
+ # candidate shape matches the keys the PA_* extractors below expect exactly.
986
+ resp = api_get(
987
+ "/api/v1/linkedin-candidates/next-pending",
988
+ {
989
+ "batch_id": batch_id,
990
+ "project": project,
991
+ "search_topic": search_topic,
992
+ "max_age_hours": 96,
993
+ },
994
+ )
995
+ cand = (resp.get("data") or {}).get("candidate")
996
+ if not cand:
997
+ print(json.dumps({}))
998
+ else:
999
+ out = {
1000
+ "post_url": cand.get("post_url") or "",
1001
+ "activity_id": cand.get("activity_id") or "",
1002
+ "all_urns": cand.get("all_urns") or "",
1003
+ "author_name": cand.get("author_name") or "",
1004
+ "author_profile_url": cand.get("author_profile_url") or "",
1005
+ "post_text": cand.get("post_text") or "",
1006
+ "language": cand.get("language") or "en",
1007
+ "project": cand.get("project") or project,
1008
+ "velocity_score": float(cand.get("velocity_score") or 0),
1009
+ "search_query": cand.get("search_query") or "",
1010
+ "search_topic": cand.get("search_topic") or search_topic,
1011
+ }
1012
+ print(json.dumps(out))
1013
+ PY
1014
+ )
1015
+
1016
+ PA_URL=$(echo "$PA_PICK" | python3 -c "import json,sys; print(json.load(sys.stdin).get('post_url',''))")
1017
+ PA_ACTIVITY_ID=$(echo "$PA_PICK" | python3 -c "import json,sys; print(json.load(sys.stdin).get('activity_id',''))")
1018
+ PA_ALL_URNS=$(echo "$PA_PICK" | python3 -c "import json,sys; print(json.load(sys.stdin).get('all_urns',''))")
1019
+ PA_AUTHOR_NAME=$(echo "$PA_PICK" | python3 -c "import json,sys; print(json.load(sys.stdin).get('author_name',''))")
1020
+ PA_AUTHOR_URL=$(echo "$PA_PICK" | python3 -c "import json,sys; print(json.load(sys.stdin).get('author_profile_url',''))")
1021
+ PA_EXCERPT=$(echo "$PA_PICK" | python3 -c "import json,sys; print(json.load(sys.stdin).get('post_text',''))")
1022
+ PA_LANG=$(echo "$PA_PICK" | python3 -c "import json,sys; print(json.load(sys.stdin).get('language','en'))")
1023
+ PA_TITLE_HINT=$(echo "$PA_PICK" | python3 -c "import json,sys; v=json.load(sys.stdin).get('post_text',''); print((v or '').split('\\n')[0])")
1024
+ PA_VELOCITY=$(echo "$PA_PICK" | python3 -c "import json,sys; print(json.load(sys.stdin).get('velocity_score',0))")
1025
+ PA_QUERY=$(echo "$PA_PICK" | python3 -c "import json,sys; print(json.load(sys.stdin).get('search_query',''))")
1026
+ PA_SEARCH_TOPIC=$(echo "$PA_PICK" | python3 -c "import json,sys; print(json.load(sys.stdin).get('search_topic',''))")
1027
+ [ -z "$PA_SEARCH_TOPIC" ] && PA_SEARCH_TOPIC="$LI_SEARCH_TOPIC"
1028
+ [ -z "${PA_PROJECT:-}" ] && PA_PROJECT=$(echo "$PA_PICK" | python3 -c "import json,sys; print(json.load(sys.stdin).get('project',''))")
1029
+
1030
+ # ===== If no candidate, exit cleanly =====
1031
+ # Path D: Phase A's LLM is responsible for clicking-into-best to capture the
1032
+ # URN, so every row reaching this gate must already have a numeric URN.
1033
+ if [ -z "$PA_ACTIVITY_ID" ] || [ -z "$PA_URL" ]; then
1034
+ echo "Phase A: no postable candidate after scoring (project='$PA_PROJECT' topic='$PA_SEARCH_TOPIC'). Skipping Phase B." | tee -a "$LOG_FILE"
1035
+ rm -f "$PHASE_A_OUT"
1036
+ ELAPSED=$(( $(date +%s) - RUN_START_EPOCH ))
1037
+ _COST=$(python3 "$REPO_DIR/scripts/get_run_cost.py" --since "$RUN_START_EPOCH" --scripts "run-linkedin-phaseA" "run-linkedin-phaseB" 2>/dev/null || echo "0.0000")
1038
+ python3 "$REPO_DIR/scripts/log_run.py" --script post_linkedin --posted 0 --skipped 1 --failed 0 --cost "$_COST" --elapsed "$ELAPSED" || true
1039
+ _SA_RUN_SUMMARY_EMITTED=1
1040
+ echo "=== Run complete: $(date) ===" | tee -a "$LOG_FILE"
1041
+ exit 0
1042
+ fi
1043
+
1044
+ # activity_id must be 16-19 digit numeric.
1045
+ case "$PA_ACTIVITY_ID" in
1046
+ ''|*[!0-9]*)
1047
+ echo "Phase A picked non-numeric activity_id '$PA_ACTIVITY_ID'. Skipping Phase B." | tee -a "$LOG_FILE"
1048
+ rm -f "$PHASE_A_OUT"
1049
+ ELAPSED=$(( $(date +%s) - RUN_START_EPOCH ))
1050
+ _COST=$(python3 "$REPO_DIR/scripts/get_run_cost.py" --since "$RUN_START_EPOCH" --scripts "run-linkedin-phaseA" "run-linkedin-phaseB" 2>/dev/null || echo "0.0000")
1051
+ python3 "$REPO_DIR/scripts/log_run.py" --script post_linkedin --posted 0 --skipped 0 --failed 1 --cost "$_COST" --elapsed "$ELAPSED" || true
1052
+ _SA_RUN_SUMMARY_EMITTED=1
1053
+ echo "=== Run complete: $(date) ===" | tee -a "$LOG_FILE"
1054
+ exit 0
1055
+ ;;
1056
+ esac
1057
+
1058
+ # Build canonical URL. Trust the row's post_url if it's already a
1059
+ # well-formed feed/update/urn:li:(activity|share|ugcPost):NUMERIC/ URL,
1060
+ # because activity / share / ugcPost are DIFFERENT namespaces. Falling
1061
+ # back to "always urn:li:activity:" caused "Post not found" 404s on
1062
+ # share-namespace posts (Andreas Mautsch / Apple Container, 2026-05-01).
1063
+ if [[ "$PA_URL" =~ ^https://www\.linkedin\.com/feed/update/urn:li:(activity|share|ugcPost):[0-9]{16,19}/?$ ]]; then
1064
+ # Already canonical with correct namespace — use it verbatim, just
1065
+ # ensure trailing slash.
1066
+ case "$PA_URL" in */) ;; *) PA_URL="$PA_URL/" ;; esac
1067
+ else
1068
+ # No usable post_url on the row (legacy / malformed). Fall back to
1069
+ # building from activity_id; default namespace is 'activity' which is
1070
+ # correct for the historical majority. If the post is actually a
1071
+ # share/ugcPost, Phase B's URN-type fallback (below) will recover.
1072
+ PA_URL="https://www.linkedin.com/feed/update/urn:li:activity:${PA_ACTIVITY_ID}/"
1073
+ fi
1074
+
1075
+ # The UniPile comment endpoint addresses a post by its social_id (the
1076
+ # urn:li:<ns>:<num> embedded in the canonical URL), not the bare numeric.
1077
+ # Extract it from PA_URL so Phase B's UniPile branch can POST to
1078
+ # /posts/{social_id}/comments. Harmless/unused for the browser path.
1079
+ _pa_url_tail="${PA_URL#*/feed/update/}"
1080
+ PA_SOCIAL_ID="${_pa_url_tail%/}"
1081
+
1082
+ echo "Phase A: chose project=$PA_PROJECT topic='$PA_SEARCH_TOPIC' activity=$PA_ACTIVITY_ID velocity=$PA_VELOCITY query='$PA_QUERY'" | tee -a "$LOG_FILE"
1083
+
1084
+ # Look up the chosen project's full config (only this one).
1085
+ PROJECT_FULL=$(python3 -c "
1086
+ import json, os
1087
+ c = json.load(open(os.path.expanduser('~/social-autoposter/config.json')))
1088
+ p = next((p for p in c.get('projects',[]) if p['name']=='$PA_PROJECT'), {})
1089
+ print(json.dumps(p, indent=2))
1090
+ ")
1091
+
1092
+ # Phase B inputs (only Phase B needs styles + top performers).
1093
+ # Engagement-style picker (2026-05-31 LinkedIn alignment to Twitter): pick ONE
1094
+ # assigned style for this cycle PROGRAMMATICALLY, then hand it to the Claude
1095
+ # session instead of letting the post pipeline invent freely (the legacy
1096
+ # generate_styles_block path). The picked style flows three places, identical
1097
+ # to run-twitter-cycle.sh: (1) --style filter for top_performers.py so the
1098
+ # exemplars section shows only posts matching the assigned style, (2)
1099
+ # saps_render_style_block so the prompt block embeds the same assignment, (3)
1100
+ # --assigned-style/--assigned-mode flags on log_post.py so the post pipeline
1101
+ # coerces USE-mode drift back to the assigned name and registers INVENT-mode
1102
+ # inventions. On invent mode PICKED_STYLE is empty and top_performers stays
1103
+ # unfiltered (model sees the full landscape to invent against).
1104
+ source "$REPO_DIR/skill/styles.sh"
1105
+ STYLE_ASSIGN_FILE=$(mktemp -t saps_linkedin_assign_XXXXXX.json)
1106
+ saps_pick_style linkedin posting "$STYLE_ASSIGN_FILE" >/dev/null 2>&1 || true
1107
+ PICKED_STYLE=$(python3 -c "
1108
+ import json
1109
+ try:
1110
+ with open('$STYLE_ASSIGN_FILE') as f:
1111
+ d = json.load(f)
1112
+ print(d.get('style') or '')
1113
+ except Exception:
1114
+ print('')
1115
+ " 2>/dev/null)
1116
+ PICKED_MODE=$(python3 -c "
1117
+ import json
1118
+ try:
1119
+ with open('$STYLE_ASSIGN_FILE') as f:
1120
+ d = json.load(f)
1121
+ print(d.get('mode') or 'use')
1122
+ except Exception:
1123
+ print('use')
1124
+ " 2>/dev/null)
1125
+ echo "Engagement style assigned: mode=$PICKED_MODE style=${PICKED_STYLE:-(invent)}" | tee -a "$LOG_FILE"
1126
+
1127
+ if [ -n "$PICKED_STYLE" ]; then
1128
+ TOP_REPORT=$(python3 "$REPO_DIR/scripts/top_performers.py" --platform linkedin --style "$PICKED_STYLE" 2>/dev/null || echo "(top performers report unavailable)")
1129
+ else
1130
+ TOP_REPORT=$(python3 "$REPO_DIR/scripts/top_performers.py" --platform linkedin 2>/dev/null || echo "(top performers report unavailable)")
1131
+ fi
1132
+ STYLES_BLOCK=$(saps_render_style_block "$STYLE_ASSIGN_FILE" linkedin posting)
1133
+ # Best-effort cleanup of the assignment tempfile at wrapper exit.
1134
+ trap 'rm -f "$STYLE_ASSIGN_FILE" 2>/dev/null || true' EXIT
1135
+
1136
+ # Prior-interactions context: surface our last 5 comments on threads by the
1137
+ # same author in the past 30 days (soft context — vary angle, don't repeat).
1138
+ # Empty when we have no history with this person. Failure is silent.
1139
+ AUTHOR_HISTORY_BLOCK=""
1140
+ if [ -n "${PA_AUTHOR_NAME:-}" ]; then
1141
+ AUTHOR_HISTORY_BLOCK=$(python3 "$REPO_DIR/scripts/author_history_block.py" --platform linkedin --author "$PA_AUTHOR_NAME" --days 30 --limit 5 2>>"$LOG_FILE" || true)
1142
+ fi
1143
+
1144
+ PA_SEARCH_TOPIC_ARG=$(python3 -c "import shlex,sys; print(shlex.quote(sys.argv[1]))" "$PA_SEARCH_TOPIC")
1145
+
1146
+ # ===== Link-tail decision (Twitter-style) =====
1147
+ # LinkedIn comments are engagement-only by default; the drafting prompt never
1148
+ # emits a URL, so wrap-post-text (which only short-links URLs already present)
1149
+ # is a no-op and our comments carry no link. Mirror the Twitter "link tail":
1150
+ # resolve the project's clean landing URL, A/B-gate it, and when the arm is
1151
+ # 'link' have Phase B append ONE CTA bridge sentence ending in that URL via
1152
+ # link_tail.py (then wrap-post-text short-links it). Control arm posts no link.
1153
+ LINK_URL=$(python3 -c "import json,sys; p=json.loads(sys.argv[1]); print((p.get('website') or p.get('url') or '').strip())" "$PROJECT_FULL")
1154
+ LINKEDIN_TAIL_LINK_RATE="${LINKEDIN_TAIL_LINK_RATE:-0.5}"
1155
+ TAIL_DECISION=$(python3 -c "import random,sys; url=sys.argv[1].strip(); rate=float(sys.argv[2]); print('link' if (url and random.random()<rate) else 'no_link')" "$LINK_URL" "$LINKEDIN_TAIL_LINK_RATE")
1156
+ echo "[link-tail] project=$PA_PROJECT url=$LINK_URL rate=$LINKEDIN_TAIL_LINK_RATE decision=$TAIL_DECISION" | tee -a "$LOG_FILE"
1157
+
1158
+ # Allow Chrome's profile lockfile to release between phases.
1159
+ sleep 3
1160
+
1161
+ # ===== Phase B: compose + post + verify + log =====
1162
+ PHASE_B_PROMPT=$(mktemp /tmp/sa-run-linkedin-phaseB-prompt-XXXXXX)
1163
+ # --- DORMANT unipile branch: OFF by default (see header). Reached ONLY with an
1164
+ # --- explicit LINKEDIN_BACKEND=unipile override, which still 503s until the
1165
+ # --- UniPile account is manually reconnected. Presence here != in use.
1166
+ if [ "$LINKEDIN_BACKEND" = "unipile" ]; then
1167
+ # ----- Phase B prompt: UniPile REST backend (no browser) -----
1168
+ cat > "$PHASE_B_PROMPT" <<PROMPT_EOF
1169
+ You are the Social Autoposter (Phase B), running on the UniPile REST backend
1170
+ (no browser). Your job: post ONE comment on a pre-selected LinkedIn post
1171
+ (already chosen + scored by Phase A) via the UniPile API, verify it landed by
1172
+ reading the post's comments back, log it. STOP. Do NOT search for other
1173
+ candidates.
1174
+
1175
+ Read $SKILL_FILE for tone and content rules.
1176
+
1177
+ ## Pre-selected candidate (from Phase A — DO NOT rediscover)
1178
+ - Project: **$PA_PROJECT**
1179
+ - Thread URL: $PA_URL
1180
+ - Post social_id (UniPile comment target): $PA_SOCIAL_ID
1181
+ - Activity URN (numeric): $PA_ACTIVITY_ID
1182
+ - All URNs already seen: $PA_ALL_URNS
1183
+ - Author: $PA_AUTHOR_NAME ($PA_AUTHOR_URL)
1184
+ - Post excerpt: $PA_EXCERPT
1185
+ - Post title hint: $PA_TITLE_HINT
1186
+ - Language: $PA_LANG
1187
+ - Velocity score: $PA_VELOCITY (Phase A picked this as the top candidate)
1188
+ - Search topic that guided discovery: '$PA_SEARCH_TOPIC'
1189
+ - Search query that surfaced it: '$PA_QUERY'
1190
+
1191
+ $AUTHOR_HISTORY_BLOCK
1192
+
1193
+ ## Project config
1194
+ $PROJECT_FULL
1195
+
1196
+ ## Top performers feedback (use to pick a comment angle)
1197
+ $TOP_REPORT
1198
+
1199
+ $STYLES_BLOCK
1200
+
1201
+ ## Workflow
1202
+
1203
+ 1. Defensive engaged-id re-check. Run via Bash:
1204
+ python3 $REPO_DIR/scripts/linkedin_url.py --check-engaged-ids '$PA_ACTIVITY_ID'
1205
+ If exit code 0 (already engaged), mark the candidate skipped:
1206
+ python3 -c "import sys; sys.path.insert(0,'$REPO_DIR/scripts'); from http_api import api_patch; api_patch('/api/v1/linkedin-candidates', {'activity_id': '$PA_ACTIVITY_ID', 'status': 'skipped'}, ok_on_404=True)"
1207
+ then STOP with '## Already engaged (defensive catch in Phase B)'.
1208
+
1209
+ 2. Draft the comment using the ASSIGNED engagement style (the style block above
1210
+ already assigns exactly one). This cycle: mode=$PICKED_MODE
1211
+ style='${PICKED_STYLE:-(invent)}'.
1212
+ - In USE mode ($PICKED_MODE=use) you MUST apply the assigned style
1213
+ '${PICKED_STYLE}' verbatim; do NOT pick a different style, do NOT invent a new
1214
+ name. (If your draft drifts, the orchestrator silently coerces it back to
1215
+ the assigned name at log time, so just use the assigned one.)
1216
+ - In INVENT mode ($PICKED_MODE=invent) you craft a NEW snake_case style name
1217
+ not in the curated block above, fitting the post + project. When you log
1218
+ (step 5 rejected / step 6 success), ALSO append this flag to the log_post.py
1219
+ command so the invention registers in engagement_styles_registry:
1220
+ --new-style '{\"description\":\"...\",\"example\":\"...\",\"why_existing_didnt_fit\":\"...\"}'
1221
+ (OMIT --new-style entirely in USE mode.)
1222
+ Apply the project's voice block (voice.tone, never violate voice.never,
1223
+ mirror voice.examples if present). Reply in $PA_LANG.
1224
+ NEVER use em dashes.
1225
+
1226
+ 2a. LINK TAIL (A/B-gated, decided by the wrapper). The decision for THIS run is:
1227
+ TAIL_LINK_DECISION = '$TAIL_DECISION'
1228
+ LINK_URL = '$LINK_URL'
1229
+ If TAIL_LINK_DECISION is 'link' AND LINK_URL is non-empty, append ONE short
1230
+ CTA bridge sentence ending in LINK_URL to your draft. Run via Bash:
1231
+ TAIL_RESULT=\$(python3 $REPO_DIR/scripts/link_tail.py \\
1232
+ --reply-text "YOUR_COMMENT_TEXT" \\
1233
+ --link-url '$LINK_URL' \\
1234
+ --thread-text "$PA_EXCERPT" \\
1235
+ --project '$PA_PROJECT' \\
1236
+ --platform linkedin)
1237
+ echo "\$TAIL_RESULT"
1238
+ Parse {ok, text}. If ok is true, REPLACE your draft with tail_result.text
1239
+ (it now ends in the URL); that becomes YOUR_COMMENT_TEXT for every step
1240
+ below. If ok is false, keep your original draft (no link this run).
1241
+ If TAIL_LINK_DECISION is 'no_link' OR LINK_URL is empty, SKIP this step and
1242
+ do NOT add any URL yourself (this is the control arm).
1243
+
1244
+ 2b. Wrap any URLs in your draft before posting. Run:
1245
+ WRAP_RESULT=\$(python3 $REPO_DIR/scripts/dm_short_links.py wrap-post-text \\
1246
+ --text "YOUR_COMMENT_TEXT" --platform linkedin --project '$PA_PROJECT')
1247
+ If wrap_result.ok is true: use wrap_result.text as the final comment text
1248
+ and save wrap_result.minted_session as MINTED_SESSION. Otherwise use the
1249
+ original draft and set MINTED_SESSION to empty.
1250
+
1251
+ 3. Post the comment via the UniPile API (use the possibly-wrapped text from 2b):
1252
+ COMMENT_RESULT=\$(python3 $REPO_DIR/scripts/linkedin_unipile.py comment --social-id '$PA_SOCIAL_ID' --text "YOUR_COMMENT_TEXT")
1253
+ echo "\$COMMENT_RESULT"
1254
+ The command prints JSON {ok, status, response, comment_urn, our_url} and
1255
+ exits 0 iff ok. A successful post is status 200 or 201 with
1256
+ response.object == "CommentSent" and (usually) a numeric response.comment_id.
1257
+
1258
+ 4. POST-SUBMIT VERIFICATION (mandatory). Extract ok + comment_id:
1259
+ COMMENT_OK=\$(python3 -c "import json,sys; print(json.loads(sys.argv[1]).get('ok'))" "\$COMMENT_RESULT" 2>/dev/null || echo "False")
1260
+ COMMENT_ID=\$(python3 -c "import json,sys; d=json.loads(sys.argv[1]); r=d.get('response') or {}; print(r.get('comment_id') or '')" "\$COMMENT_RESULT" 2>/dev/null || echo "")
1261
+ Then read the comment back from the post to PROVE it rendered:
1262
+ python3 $REPO_DIR/scripts/linkedin_unipile.py comments --social-id '$PA_SOCIAL_ID' --contains-id "\$COMMENT_ID"
1263
+ This command exits 0 iff our comment_id is present in the post's comment list.
1264
+ SUCCESS = COMMENT_OK is "True" AND (the read-back exited 0, OR COMMENT_ID
1265
+ was empty but COMMENT_RESULT showed status 201 / object CommentSent).
1266
+ REJECTED = ok false, non-2xx status, or an error response object.
1267
+
1268
+ 5. If REJECTED, do NOT call the success log path. Mark candidate skipped:
1269
+ python3 -c "import sys; sys.path.insert(0,'$REPO_DIR/scripts'); from http_api import api_patch; api_patch('/api/v1/linkedin-candidates', {'activity_id': '$PA_ACTIVITY_ID', 'status': 'skipped'}, ok_on_404=True)"
1270
+ Then ledger the soft-block:
1271
+ python3 $REPO_DIR/scripts/log_post.py --rejected \\
1272
+ --platform linkedin \\
1273
+ --thread-url '$PA_URL' \\
1274
+ --our-content 'YOUR_COMMENT_TEXT' \\
1275
+ --project '$PA_PROJECT' \\
1276
+ --thread-author '$PA_AUTHOR_NAME' \\
1277
+ --thread-title '$PA_TITLE_HINT' \\
1278
+ --engagement-style STYLE_YOU_CHOSE \\
1279
+ --assigned-style '$PICKED_STYLE' \\
1280
+ --assigned-mode '$PICKED_MODE' \\
1281
+ --search-topic $PA_SEARCH_TOPIC_ARG \\
1282
+ --language '$PA_LANG' \\
1283
+ --rejection-reason 'UNIPILE: <verbatim status + response.object/error from COMMENT_RESULT>' \\
1284
+ --network-response "\$COMMENT_RESULT"
1285
+ Then STOP with '## Comment soft-blocked, ledgered'.
1286
+
1287
+ 6. If SUCCESS, log the post and mark candidate posted:
1288
+ LOG_RESULT=\$(python3 $REPO_DIR/scripts/log_post.py \\
1289
+ --platform linkedin \\
1290
+ --thread-url '$PA_URL' \\
1291
+ --our-url '$PA_URL' \\
1292
+ --our-content 'YOUR_COMMENT_TEXT' \\
1293
+ --project '$PA_PROJECT' \\
1294
+ --thread-author '$PA_AUTHOR_NAME' \\
1295
+ --thread-title '$PA_TITLE_HINT' \\
1296
+ --engagement-style STYLE_YOU_CHOSE \\
1297
+ --assigned-style '$PICKED_STYLE' \\
1298
+ --assigned-mode '$PICKED_MODE' \\
1299
+ --search-topic $PA_SEARCH_TOPIC_ARG \\
1300
+ --language '$PA_LANG' \\
1301
+ --urns '$PA_ACTIVITY_ID')
1302
+ echo "\$LOG_RESULT"
1303
+ python3 -c "import sys; sys.path.insert(0,'$REPO_DIR/scripts'); from http_api import api_patch; api_patch('/api/v1/linkedin-candidates', {'activity_id': '$PA_ACTIVITY_ID', 'status': 'posted'}, ok_on_404=True)"
1304
+ If MINTED_SESSION is non-empty: extract post_id from LOG_RESULT and backfill:
1305
+ LOG_POST_ID=\$(python3 -c "import json,sys; print(json.loads(sys.argv[1]).get('post_id',''))" "\$LOG_RESULT" 2>/dev/null || echo "")
1306
+ [ -n "\$LOG_POST_ID" ] && python3 $REPO_DIR/scripts/dm_short_links.py backfill-post \\
1307
+ --minted-session "\$MINTED_SESSION" --post-id "\$LOG_POST_ID"
1308
+
1309
+ CRITICAL: ONE post only. If anything fails, STOP — do NOT pick another candidate.
1310
+ CRITICAL: Use ONLY the Bash tool plus linkedin_unipile.py / log_post.py /
1311
+ dm_short_links.py / linkedin_url.py. There is NO browser; NEVER attempt
1312
+ any browser MCP tools (none are loaded).
1313
+ CRITICAL: NEVER use em dashes.
1314
+ PROMPT_EOF
1315
+ else
1316
+ # ----- Phase B prompt: headed-Chrome browser backend (linkedin-harness) -----
1317
+ cat > "$PHASE_B_PROMPT" <<PROMPT_EOF
1318
+ You are the Social Autoposter (Phase B). Your job: post ONE comment on a
1319
+ pre-selected LinkedIn post (already chosen + scored by Phase A), verify it
1320
+ landed, log it. STOP. Do NOT search for other candidates.
1321
+
1322
+ Read $SKILL_FILE for tone and content rules.
1323
+
1324
+ $BROWSER_INSTRUCTIONS
1325
+
1326
+ ## Pre-selected candidate (from Phase A — DO NOT rediscover)
1327
+ - Project: **$PA_PROJECT**
1328
+ - Thread URL: $PA_URL
1329
+ - Activity URN: $PA_ACTIVITY_ID
1330
+ - All URNs already seen: $PA_ALL_URNS
1331
+ - Author: $PA_AUTHOR_NAME ($PA_AUTHOR_URL)
1332
+ - Post excerpt: $PA_EXCERPT
1333
+ - Post title hint: $PA_TITLE_HINT
1334
+ - Language: $PA_LANG
1335
+ - Velocity score: $PA_VELOCITY (Phase A picked this as the top candidate)
1336
+ - Search topic that guided discovery: '$PA_SEARCH_TOPIC'
1337
+ - Search query that surfaced it: '$PA_QUERY'
1338
+
1339
+ $AUTHOR_HISTORY_BLOCK
1340
+
1341
+ ## Project config
1342
+ $PROJECT_FULL
1343
+
1344
+ ## Top performers feedback (use to pick a comment angle)
1345
+ $TOP_REPORT
1346
+
1347
+ $STYLES_BLOCK
1348
+
1349
+ ## Workflow
1350
+
1351
+ 1. Navigate to $PA_URL (per the BROWSER BACKEND block).
1352
+
1353
+ 1a. URN-NAMESPACE FALLBACK. After navigation, read the page DOM/text (per the
1354
+ BROWSER BACKEND block: bh_run js("""return document.body.innerText""") or a
1355
+ screenshot). If it contains the markers 'Post not found' OR 'This post
1356
+ was deleted or removed' OR 'this content isn'\''t available', the
1357
+ URN namespace in $PA_URL may be wrong (activity/share/ugcPost are
1358
+ DIFFERENT namespaces with different numeric IDs — Phase A may have
1359
+ guessed wrong on a copy-link path). Before declaring the post
1360
+ unavailable, retry the other two namespaces:
1361
+
1362
+ * Extract the bare numeric '$PA_ACTIVITY_ID'.
1363
+ * Extract the current namespace from $PA_URL (one of activity, share, ugcPost).
1364
+ * Try each of the OTHER two namespaces in turn:
1365
+ - https://www.linkedin.com/feed/update/urn:li:share:$PA_ACTIVITY_ID/
1366
+ - https://www.linkedin.com/feed/update/urn:li:ugcPost:$PA_ACTIVITY_ID/
1367
+ - https://www.linkedin.com/feed/update/urn:li:activity:$PA_ACTIVITY_ID/
1368
+ (skip whichever you already tried). Navigate to each (per the
1369
+ BROWSER BACKEND block); after each, read the DOM/text the same way;
1370
+ if the post-not-found markers are absent AND a comment editor / post
1371
+ body renders, that URL is the correct one — adopt it and continue
1372
+ from step 2.
1373
+ * If ALL THREE namespaces hit post-not-found markers, the post
1374
+ genuinely no longer exists. Mark candidate skipped:
1375
+ python3 -c "import sys; sys.path.insert(0,'$REPO_DIR/scripts'); from http_api import api_patch; api_patch('/api/v1/linkedin-candidates', {'activity_id': '$PA_ACTIVITY_ID', 'status': 'skipped'}, ok_on_404=True)"
1376
+ Update the run-level counter signal: print a line containing
1377
+ the literal token 'PHASE_B_SKIP_POST_UNAVAILABLE' so the wrapper
1378
+ can attribute it. Then STOP with '## Post unavailable, candidate skipped'.
1379
+
1380
+ 1b. If you found a working namespace different from $PA_URL, persist it
1381
+ so future navigations / engaged-id checks use the right canonical:
1382
+ python3 -c "import sys; sys.path.insert(0,'$REPO_DIR/scripts'); from http_api import api_patch; api_patch('/api/v1/linkedin-candidates', {'activity_id': '$PA_ACTIVITY_ID', 'post_url': '<WORKING_URL>'}, ok_on_404=True)"
1383
+
1384
+ 2. Defensive engaged-id re-check (Phase A may have missed a URN that only
1385
+ surfaces after the post page fully loads). Walk the rendered DOM for ALL
1386
+ URNs (activity, share, ugcPost forms), merge with '$PA_ALL_URNS', and run:
1387
+ python3 $REPO_DIR/scripts/linkedin_url.py --check-engaged-ids 'MERGED_URNS'
1388
+ If exit code 0 (already engaged), mark the candidate skipped:
1389
+ python3 -c "import sys; sys.path.insert(0,'$REPO_DIR/scripts'); from http_api import api_patch; api_patch('/api/v1/linkedin-candidates', {'activity_id': '$PA_ACTIVITY_ID', 'status': 'skipped'}, ok_on_404=True)"
1390
+ then STOP with '## Already engaged (defensive catch in Phase B)'.
1391
+
1392
+ 3. Draft the comment using the ASSIGNED engagement style (the style block above
1393
+ already assigns exactly one). This cycle: mode=$PICKED_MODE
1394
+ style='${PICKED_STYLE:-(invent)}'.
1395
+ - In USE mode ($PICKED_MODE=use) you MUST apply the assigned style
1396
+ '${PICKED_STYLE}' verbatim; do NOT pick a different style, do NOT invent a new
1397
+ name. (If your draft drifts, the orchestrator silently coerces it back to
1398
+ the assigned name at log time, so just use the assigned one.)
1399
+ - In INVENT mode ($PICKED_MODE=invent) you craft a NEW snake_case style name
1400
+ not in the curated block above, fitting the post + project. When you log
1401
+ (step 6 rejected / step 7 success), ALSO append this flag to the log_post.py
1402
+ command so the invention registers in engagement_styles_registry:
1403
+ --new-style '{\"description\":\"...\",\"example\":\"...\",\"why_existing_didnt_fit\":\"...\"}'
1404
+ (OMIT --new-style entirely in USE mode.)
1405
+ Apply the project's voice block (voice.tone, never violate voice.never,
1406
+ mirror voice.examples if present). Reply in $PA_LANG.
1407
+ NEVER use em dashes.
1408
+
1409
+ 3a. LINK TAIL (A/B-gated, decided by the wrapper). The decision for THIS run is:
1410
+ TAIL_LINK_DECISION = '$TAIL_DECISION'
1411
+ LINK_URL = '$LINK_URL'
1412
+ If TAIL_LINK_DECISION is 'link' AND LINK_URL is non-empty, append ONE short
1413
+ CTA bridge sentence ending in LINK_URL to your draft. Run via Bash:
1414
+ TAIL_RESULT=\$(python3 $REPO_DIR/scripts/link_tail.py \\
1415
+ --reply-text "YOUR_COMMENT_TEXT" \\
1416
+ --link-url '$LINK_URL' \\
1417
+ --thread-text "$PA_EXCERPT" \\
1418
+ --project '$PA_PROJECT' \\
1419
+ --platform linkedin)
1420
+ echo "\$TAIL_RESULT"
1421
+ Parse {ok, text}. If ok is true, REPLACE your draft with tail_result.text
1422
+ (it now ends in the URL); that becomes YOUR_COMMENT_TEXT for every step
1423
+ below. If ok is false, keep your original draft (no link this run).
1424
+ If TAIL_LINK_DECISION is 'no_link' OR LINK_URL is empty, SKIP this step and
1425
+ do NOT add any URL yourself (this is the control arm).
1426
+
1427
+ 3b. Wrap any URLs in your draft before typing. Run:
1428
+ WRAP_RESULT=\$(python3 $REPO_DIR/scripts/dm_short_links.py wrap-post-text \\
1429
+ --text "YOUR_COMMENT_TEXT" --platform linkedin --project '$PA_PROJECT')
1430
+ If wrap_result.ok is true: use wrap_result.text as the final comment text
1431
+ and save wrap_result.minted_session as MINTED_SESSION. Otherwise use the
1432
+ original draft and set MINTED_SESSION to empty.
1433
+
1434
+ 3c. AUTO-LIKE the main post (mandatory, deterministic, FAIL-SOFT). Before you
1435
+ comment, react Like to the post itself, mirroring the Twitter pipeline
1436
+ (every successful engagement also likes the parent). This is fail-soft: a
1437
+ like failure must NEVER block or fail the comment. If it doesn't work in
1438
+ two tries, log 'auto-like skipped' and proceed straight to step 4.
1439
+ Primary (deterministic) path via the BROWSER BACKEND block:
1440
+ bh_run js("""return (() => { const btn = document.querySelector('button.react-button__trigger, .feed-shared-social-action-bar button[aria-label*=Like]'); if(!btn) return JSON.stringify({ok:false, reason:'no_button'}); const pressed = (btn.getAttribute('aria-pressed')||'').toLowerCase(); if(pressed==='true') return JSON.stringify({ok:true, already_liked:true}); btn.click(); return JSON.stringify({ok:true, clicked:true}); })()""")
1441
+ Parse the JSON:
1442
+ - ok:true, already_liked:true → post was already liked, do nothing.
1443
+ - ok:true, clicked:true → liked. Optionally screenshot to confirm
1444
+ the reaction bar shows the filled Like.
1445
+ - ok:false (no_button) → the deterministic selector missed.
1446
+ Fallback ONCE: capture a screenshot, Read
1447
+ it, locate the post's Like button (the
1448
+ leftmost action under the post body, NOT
1449
+ a Like on any comment), and click_at_xy
1450
+ it. If still not found, skip the like.
1451
+ NEVER click Like on a comment or on a different post; only the main
1452
+ pre-selected post. NEVER un-like (the aria-pressed guard prevents toggling
1453
+ an already-liked post off). Record AUTO_LIKE = liked | already | skipped
1454
+ for your final summary, then continue to step 4 regardless of outcome.
1455
+
1456
+ 4. Post the comment via the BROWSER BACKEND block: scroll to the comment
1457
+ editor, click it (click_at_xy on the contenteditable box), type_text the
1458
+ (possibly wrapped) text from step 3b, then click the Post/Comment submit
1459
+ button (click_at_xy). The contenteditable box is the trickiest element —
1460
+ after clicking, capture a screenshot and Read it to confirm the caret is in
1461
+ the editor before typing.
1462
+
1463
+ 5. POST-SUBMIT VERIFICATION (mandatory). The harness has NO network-capture
1464
+ tool, and reading /voyager or socialActions traffic is a flagged pattern —
1465
+ verify visually + via the rendered DOM only.
1466
+ 5a. Harvest URNs from the rendered DOM (NOT from network). Read every
1467
+ 16-19 digit URN present on the page:
1468
+ bh_run js("""return JSON.stringify(Array.from(document.querySelectorAll('[data-id],[data-urn],[href]')).map(e=>e.getAttribute('data-id')||e.getAttribute('data-urn')||e.getAttribute('href')).join(' ').match(/urn:li:(?:activity|share|ugcPost|comment):[0-9]{16,19}/g)||[])""")
1469
+ Dedupe the result with the seed URN list above into ALL_POST_URNS
1470
+ (comma-separated). Set NETWORK_RESPONSE to a short DOM/toast summary
1471
+ string (there is no real network payload to capture).
1472
+ 5b. Capture a screenshot (bh_run print(capture_screenshot())) and Read the PNG
1473
+ to check for a toast.
1474
+ 5c. Read the DOM (bh_run js("""...""")) and check:
1475
+ (a) comment count went up by at least 1
1476
+ (b) a fresh comment by 'Matthew Diakonov' / 'You' is rendered
1477
+ (c) NO 'could not be created' toast
1478
+ (d) editor textbox cleared
1479
+ 5d. SUCCESS = all four pass. REJECTED = toast present OR count unchanged.
1480
+
1481
+ 5e. ON SUCCESS ONLY — capture OUR comment's full comment URN so stats can
1482
+ later match it. The post-stats pipeline keys engagement on the numeric
1483
+ comment id embedded in our_url's commentUrn; without it our comment's
1484
+ impressions/reactions/replies can NEVER be matched (they stay frozen).
1485
+ Read every rendered comment node WITH its text:
1486
+ bh_run js("""return JSON.stringify(Array.from(document.querySelectorAll('[data-id^="urn:li:comment:"]')).map(n=>({id:n.getAttribute('data-id'),text:(n.innerText||'').replace(/\\s+/g,' ').slice(0,160)})))""")
1487
+ From that array pick the ONE entry whose text matches the comment YOU
1488
+ just posted (YOUR_COMMENT_TEXT, possibly link-wrapped). Take its 'id' —
1489
+ it is the full parenthesized comment URN, e.g.
1490
+ urn:li:comment:(activity:7468708028956016640,7468710512147460096)
1491
+ (the trailing number is OUR comment id). Store it verbatim as
1492
+ OUR_COMMENT_URN. If you cannot confidently identify our comment (no
1493
+ data-id match), set OUR_COMMENT_URN to empty and proceed — step 7 falls
1494
+ back to the bare thread URL.
1495
+
1496
+ 6. If REJECTED, do NOT call the success log path. Mark candidate skipped:
1497
+ python3 -c "import sys; sys.path.insert(0,'$REPO_DIR/scripts'); from http_api import api_patch; api_patch('/api/v1/linkedin-candidates', {'activity_id': '$PA_ACTIVITY_ID', 'status': 'skipped'}, ok_on_404=True)"
1498
+ Then ledger the soft-block:
1499
+ python3 $REPO_DIR/scripts/log_post.py --rejected \\
1500
+ --platform linkedin \\
1501
+ --thread-url '$PA_URL' \\
1502
+ --our-content 'YOUR_COMMENT_TEXT' \\
1503
+ --project '$PA_PROJECT' \\
1504
+ --thread-author '$PA_AUTHOR_NAME' \\
1505
+ --thread-title '$PA_TITLE_HINT' \\
1506
+ --engagement-style STYLE_YOU_CHOSE \\
1507
+ --assigned-style '$PICKED_STYLE' \\
1508
+ --assigned-mode '$PICKED_MODE' \\
1509
+ --search-topic $PA_SEARCH_TOPIC_ARG \\
1510
+ --language '$PA_LANG' \\
1511
+ --rejection-reason 'TOAST: <verbatim toast text or quiet-fail>' \\
1512
+ --network-response 'NETWORK_RESPONSE'
1513
+ Then STOP with '## Comment soft-blocked, ledgered'.
1514
+
1515
+ 7. If SUCCESS, log the post and mark candidate posted. First build OUR_URL so
1516
+ it carries our comment's commentUrn (REQUIRED for stats matching). Substitute
1517
+ the OUR_COMMENT_URN you captured in step 5e in place of the literal token
1518
+ below, then run:
1519
+ OUR_URL=\$(python3 -c "import urllib.parse,sys; cu=sys.argv[1].strip(); base=sys.argv[2]; print(base + '?commentUrn=' + urllib.parse.quote(cu, safe='') if cu.startswith('urn:li:comment:(') else base)" 'OUR_COMMENT_URN' '$PA_URL')
1520
+ If OUR_COMMENT_URN was empty, OUR_URL falls back to the bare thread URL.
1521
+ LOG_RESULT=\$(python3 $REPO_DIR/scripts/log_post.py \\
1522
+ --platform linkedin \\
1523
+ --thread-url '$PA_URL' \\
1524
+ --our-url "\$OUR_URL" \\
1525
+ --our-content 'YOUR_COMMENT_TEXT' \\
1526
+ --project '$PA_PROJECT' \\
1527
+ --thread-author '$PA_AUTHOR_NAME' \\
1528
+ --thread-title '$PA_TITLE_HINT' \\
1529
+ --engagement-style STYLE_YOU_CHOSE \\
1530
+ --assigned-style '$PICKED_STYLE' \\
1531
+ --assigned-mode '$PICKED_MODE' \\
1532
+ --search-topic $PA_SEARCH_TOPIC_ARG \\
1533
+ --language '$PA_LANG' \\
1534
+ --urns 'ALL_POST_URNS')
1535
+ echo "\$LOG_RESULT"
1536
+ python3 -c "import sys; sys.path.insert(0,'$REPO_DIR/scripts'); from http_api import api_patch; api_patch('/api/v1/linkedin-candidates', {'activity_id': '$PA_ACTIVITY_ID', 'status': 'posted'}, ok_on_404=True)"
1537
+ If MINTED_SESSION is non-empty: extract post_id from LOG_RESULT and backfill:
1538
+ LOG_POST_ID=\$(python3 -c "import json,sys; print(json.loads(sys.argv[1]).get('post_id',''))" "\$LOG_RESULT" 2>/dev/null || echo "")
1539
+ [ -n "\$LOG_POST_ID" ] && python3 $REPO_DIR/scripts/dm_short_links.py backfill-post \\
1540
+ --minted-session "\$MINTED_SESSION" --post-id "\$LOG_POST_ID"
1541
+
1542
+ CRITICAL: ONE post only. If anything fails, STOP — do NOT pick another candidate.
1543
+ CRITICAL: Use ONLY the browser tool described in the BROWSER BACKEND block
1544
+ (mcp__linkedin-harness__bh_run).
1545
+ CRITICAL: NEVER use em dashes.
1546
+ PROMPT_EOF
1547
+ fi
1548
+
1549
+ # --- DORMANT unipile branch: OFF by default (see header). Reached ONLY with an
1550
+ # --- explicit LINKEDIN_BACKEND=unipile override, which still 503s until the
1551
+ # --- UniPile account is manually reconnected. Presence here != in use.
1552
+ if [ "$LINKEDIN_BACKEND" = "unipile" ]; then
1553
+ # UniPile Phase B: comment via REST, no headed browser. No linkedin-browser
1554
+ # lock, no ensure_browser_healthy, no harness MCP, no hook lockfile.
1555
+ # --strict-mcp-config with NO --mcp-config = Bash-only tool surface; the
1556
+ # agent shells out to linkedin_unipile.py comment/comments.
1557
+ set +e
1558
+ "$REPO_DIR/scripts/run_claude.sh" "run-linkedin-phaseB" --strict-mcp-config --output-format stream-json --verbose -p "$(cat "$PHASE_B_PROMPT")" 2>&1 | tee -a "$LOG_FILE"
1559
+ PB_RC=${PIPESTATUS[0]}
1560
+ set -e
1561
+ rm -f "$PHASE_B_PROMPT"
1562
+ rm -f "$PHASE_A_OUT"
1563
+ else
1564
+ # Re-acquire linkedin-browser for Phase B. The lock was released after
1565
+ # Phase A so peer pipelines could use the browser during our DB-ingest /
1566
+ # candidate-pick / styles-prep window (~1-3s). If a peer (or a parallel
1567
+ # linkedin cycle's Phase A) grabbed it in the meantime, this acquire blocks
1568
+ # until they release; the FIFO ticket queue in lock.sh guarantees fairness.
1569
+ acquire_lock "linkedin-browser" 3600
1570
+ ensure_linkedin_browser_for_backend 2>&1 | tee -a "$LOG_FILE"
1571
+
1572
+ set +e
1573
+ "$REPO_DIR/scripts/run_claude.sh" "run-linkedin-phaseB" --strict-mcp-config --mcp-config "$MCP_CONFIG_FILE" --output-format stream-json --verbose -p "$(cat "$PHASE_B_PROMPT")" 2>&1 | tee -a "$LOG_FILE"
1574
+ PB_RC=${PIPESTATUS[0]}
1575
+ set -e
1576
+
1577
+ release_lock "linkedin-browser"
1578
+ # Defense-in-depth: explicit hook-lockfile cleanup; see Phase A note.
1579
+ rm -f "$HOME/.claude/linkedin-agent-lock.json"
1580
+ rm -f "$PHASE_B_PROMPT"
1581
+ rm -f "$PHASE_A_OUT"
1582
+ fi
1583
+
1584
+ # ===== Persist run-level summary =====
1585
+ # Same logic that used to live inline now lives in
1586
+ # _sa_emit_run_summary_oneshot (defined near the top after sourcing lock.sh).
1587
+ # Calling it directly here on the happy path; the EXIT trap will short-
1588
+ # circuit afterwards via _SA_RUN_SUMMARY_EMITTED. Under SIGTERM mid-script,
1589
+ # the trap fires this same function so the dashboard still gets a row.
1590
+ _sa_emit_run_summary_oneshot
1591
+
1592
+ echo "=== Run complete: $(date) ===" | tee -a "$LOG_FILE"
1593
+ find "$LOG_DIR" -name "run-linkedin-*.log" -mtime +7 -delete 2>/dev/null || true