@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,583 @@
1
+ #!/usr/bin/env python3
2
+ """Single source of truth for the S4L status snapshot.
3
+
4
+ Produces the SAME dict as the MCP's buildSnapshot() (mcp/src/index.ts), but in
5
+ Python, reading directly from the stateful files plus two existing Python helpers
6
+ (setup_twitter_auth.py for X, schedule_state.py for the draft schedule) and
7
+ GitHub releases/latest (npm fallback) for the latest version.
8
+
9
+ WHY this exists: the menu bar must render with Claude / the MCP fully closed. The
10
+ MCP is a Node process tied to Claude Desktop's lifecycle, and it was the ONLY
11
+ thing computing the snapshot — so the always-on menu bar had to ask it over a
12
+ blocking loopback call, which froze the menu whenever the MCP was restarting.
13
+ Moving the compute here lets the menu bar build the snapshot itself from the
14
+ files (zero MCP dependency), while the MCP shells out to this SAME module so
15
+ there's one implementation, no divergence — the schedule_state.py pattern applied
16
+ to the whole snapshot. The source of truth is the FILES; this is just the reader.
17
+
18
+ PURE READ/COMPUTE: never writes (no onboarding-milestone telemetry, no
19
+ persistence) — the MCP keeps those side effects around this. Slow fields (X
20
+ session, latest version) are cached per-process with a TTL so a 5s menu-bar tick
21
+ that imports this module stays cheap.
22
+ """
23
+ from __future__ import annotations
24
+
25
+ import json
26
+ import os
27
+ import subprocess
28
+ import sys
29
+ import time
30
+
31
+ # SAPS_->S4L_ env mirror (brand rename 2026-07-03): old launchd plists and
32
+ # scheduled-task prompts still export SAPS_*; this process reads S4L_*.
33
+ import s4l_env # noqa: E402 (lives next to this file in scripts/)
34
+
35
+ s4l_env.mirror()
36
+
37
+ HOME = os.path.expanduser("~")
38
+
39
+
40
+ def _state_dir() -> str:
41
+ return os.environ.get("S4L_STATE_DIR") or os.path.join(HOME, ".social-autoposter-mcp")
42
+
43
+
44
+ def _repo_dir() -> str:
45
+ return os.environ.get("S4L_REPO_DIR") or os.path.join(HOME, "social-autoposter")
46
+
47
+
48
+ def _claude_cfg_dir() -> str:
49
+ return os.environ.get("CLAUDE_CONFIG_DIR") or os.path.join(HOME, ".claude")
50
+
51
+
52
+ def _config_path() -> str:
53
+ return os.environ.get("S4L_CONFIG_PATH") or os.path.join(_repo_dir(), "config.json")
54
+
55
+
56
+ # Keep in sync with REQUIRED_FIELDS (mcp/src/setup.ts), QUEUE_WORKERS / UPDATER_LABEL
57
+ # / AUTOPILOT_STALL_MS (mcp/src/index.ts).
58
+ REQUIRED_FIELDS = ["name", "website", "description", "icp", "voice", "search_topics"]
59
+ # Keep in sync with PERSONA_REQUIRED_FIELDS (mcp/src/setup.ts). A personal-brand
60
+ # persona has no product website/icp by design; it is "ready" once it has the fields
61
+ # the cycle consumes (name, voice, seedable topics). Without this, a personal-brand-
62
+ # only setup can NEVER report setup_complete (any_ready requires a managed product),
63
+ # leaving the menu bar stuck on "project not set up". (2026-06-30)
64
+ PERSONA_REQUIRED_FIELDS = ["name", "description", "voice", "search_topics"]
65
+ WORKER_TASK_IDS = ("saps-phase1-query", "saps-phase2b-draft")
66
+ UPDATER_LABEL = "com.m13v.social-autoposter-update"
67
+ AUTOPILOT_STALL_MS = 180_000
68
+
69
+ # Milestones overlaid with LIVE state for display (the rest keep their ledger
70
+ # value). Mirrors the overlay in buildSnapshot().
71
+ _OVERLAY_IDS = ("runtime_ready", "x_connected", "mode_chosen", "project_ready", "tasks_scheduled")
72
+
73
+
74
+ def _read_json(path: str):
75
+ try:
76
+ with open(path) as f:
77
+ return json.load(f)
78
+ except Exception:
79
+ return None
80
+
81
+
82
+ # ---- projects (config.json + setup-state.json + REQUIRED_FIELDS) -----------
83
+ def _managed_projects():
84
+ st = _read_json(os.path.join(_state_dir(), "setup-state.json")) or {}
85
+ return st.get("projects") or []
86
+
87
+
88
+ def _project_status(name, cfg_projects):
89
+ proj = next((p for p in cfg_projects if p.get("name") == name), None)
90
+ if proj is None:
91
+ return {"name": name, "ready": False, "missing_required": list(REQUIRED_FIELDS)}
92
+ missing = []
93
+ for f in REQUIRED_FIELDS:
94
+ v = proj.get(f)
95
+ if v is None:
96
+ missing.append(f)
97
+ elif isinstance(v, str) and not v.strip():
98
+ missing.append(f)
99
+ elif isinstance(v, (list, tuple)) and len(v) == 0:
100
+ missing.append(f)
101
+ elif isinstance(v, dict) and len(v) == 0:
102
+ missing.append(f)
103
+ return {"name": name, "ready": len(missing) == 0, "missing_required": missing}
104
+
105
+
106
+ def _projects():
107
+ cfg = _read_json(_config_path()) or {}
108
+ cfg_projects = cfg.get("projects") or []
109
+ return [_project_status(n, cfg_projects) for n in _managed_projects()]
110
+
111
+
112
+ def _persona_status():
113
+ """The personal-brand persona (config.json persona:true) as a project-status
114
+ dict, or None when there's no persona. Validated against PERSONA_REQUIRED_FIELDS
115
+ (a persona has no product website/icp). The persona is excluded from the managed
116
+ scope (_managed_projects) by design, but IS what the cycle drafts in
117
+ personal_brand mode, so it must count toward readiness."""
118
+ cfg = _read_json(_config_path()) or {}
119
+ persona = next((p for p in (cfg.get("projects") or []) if p.get("persona")), None)
120
+ if persona is None:
121
+ return None
122
+ missing = []
123
+ for f in PERSONA_REQUIRED_FIELDS:
124
+ v = persona.get(f)
125
+ if v is None:
126
+ missing.append(f)
127
+ elif isinstance(v, str) and not v.strip():
128
+ missing.append(f)
129
+ elif isinstance(v, (list, tuple)) and len(v) == 0:
130
+ missing.append(f)
131
+ elif isinstance(v, dict) and len(v) == 0:
132
+ missing.append(f)
133
+ return {
134
+ "name": persona.get("name") or "PersonalBrand",
135
+ "ready": len(missing) == 0,
136
+ "missing_required": missing,
137
+ "persona": True,
138
+ }
139
+
140
+
141
+ # ---- runtime / mode / autopilot (all file/launchctl) -----------------------
142
+ def _runtime_ready() -> bool:
143
+ rt = _read_json(os.path.join(_state_dir(), "runtime.json")) or {}
144
+ py = rt.get("python")
145
+ return bool(rt.get("ready") and py and os.path.exists(py))
146
+
147
+
148
+ def _runtime_provisioning() -> bool:
149
+ p = _read_json(os.path.join(_state_dir(), "install-progress.json")) or {}
150
+ return str(p.get("status") or "").lower() in ("installing", "in_progress", "running", "provisioning")
151
+
152
+
153
+ def _flags() -> dict:
154
+ """Engagement lane flags {"personal_brand": bool, "promotion": bool}.
155
+
156
+ Mirrors scripts/saps_mode.py get_flags(): explicit flag keys win; else map a
157
+ legacy {"mode": ...} string; else default personal ON / promotion OFF."""
158
+ d = _read_json(os.path.join(_state_dir(), "mode.json")) or {}
159
+ if "personal_brand" in d or "promotion" in d:
160
+ return {"personal_brand": bool(d.get("personal_brand")),
161
+ "promotion": bool(d.get("promotion"))}
162
+ m = str(d.get("mode") or "").strip()
163
+ if m == "personal_brand":
164
+ return {"personal_brand": True, "promotion": False}
165
+ if m == "promotion":
166
+ return {"personal_brand": False, "promotion": True}
167
+ return {"personal_brand": True, "promotion": False}
168
+
169
+
170
+ def _mode() -> str:
171
+ # Derived legacy single-mode string (personal wins when on).
172
+ return "personal_brand" if _flags().get("personal_brand") else "promotion"
173
+
174
+
175
+ def _mode_chosen() -> bool:
176
+ return os.path.exists(os.path.join(_state_dir(), "mode.json"))
177
+
178
+
179
+ def _autopilot_on() -> bool:
180
+ base = os.path.join(_claude_cfg_dir(), "scheduled-tasks")
181
+ try:
182
+ return all(os.path.exists(os.path.join(base, t, "SKILL.md")) for t in WORKER_TASK_IDS)
183
+ except Exception:
184
+ return False
185
+
186
+
187
+ def _auto_update_on() -> bool:
188
+ try:
189
+ out = subprocess.run(["launchctl", "list"], capture_output=True, text=True, timeout=10).stdout
190
+ return any(UPDATER_LABEL in line for line in out.splitlines())
191
+ except Exception:
192
+ return False
193
+
194
+
195
+ def _autopilot_stalled() -> bool:
196
+ qdir = os.path.join(_state_dir(), "claude-queue")
197
+ ds = _read_json(os.path.join(qdir, "drain-status.json")) or {}
198
+ try:
199
+ if int(ds.get("consecutive_timeouts") or 0) >= 1:
200
+ return True
201
+ except Exception:
202
+ pass
203
+ oldest = None
204
+ try:
205
+ pend = os.path.join(qdir, "pending")
206
+ for sub in os.listdir(pend):
207
+ subp = os.path.join(pend, sub)
208
+ if not os.path.isdir(subp):
209
+ continue
210
+ for f in os.listdir(subp):
211
+ if not f.endswith(".json") or f.endswith(".tmp"):
212
+ continue
213
+ try:
214
+ m = os.stat(os.path.join(subp, f)).st_mtime * 1000.0
215
+ if oldest is None or m < oldest:
216
+ oldest = m
217
+ except Exception:
218
+ pass
219
+ except Exception:
220
+ pass
221
+ return oldest is not None and (time.time() * 1000.0 - oldest) > AUTOPILOT_STALL_MS
222
+
223
+
224
+ # ---- schedule_state (reuse the shared module) ------------------------------
225
+ def _schedule_state() -> str:
226
+ try:
227
+ scripts = os.path.join(_repo_dir(), "scripts")
228
+ if scripts not in sys.path:
229
+ sys.path.insert(0, scripts)
230
+ import schedule_state # noqa: E402
231
+ return schedule_state.compute()
232
+ except Exception:
233
+ return "missing"
234
+
235
+
236
+ # ---- X status (setup_twitter_auth.py status), cached -----------------------
237
+ _x_cache = {"at": 0.0, "val": None}
238
+ _X_TTL = 60.0
239
+
240
+
241
+ def _x_status():
242
+ now = time.time()
243
+ if _x_cache["val"] is not None and now - _x_cache["at"] < _X_TTL:
244
+ return _x_cache["val"]
245
+ val = {"connected": False, "state": "", "handle": None}
246
+ if _runtime_ready():
247
+ try:
248
+ py = os.environ.get("S4L_PYTHON") or sys.executable or "python3"
249
+ res = subprocess.run(
250
+ [py, os.path.join(_repo_dir(), "scripts", "setup_twitter_auth.py"), "status"],
251
+ capture_output=True, text=True, timeout=90,
252
+ )
253
+ # Mirror twitterAuth.ts::parse — JSON in the last lines of stdout.
254
+ parsed = json.loads("\n".join(res.stdout.strip().splitlines()[-50:]))
255
+ val = {
256
+ "connected": bool(parsed.get("connected")),
257
+ "state": parsed.get("state") or "",
258
+ "handle": parsed.get("handle"),
259
+ }
260
+ except Exception:
261
+ val = {"connected": False, "state": "status_unavailable", "handle": None}
262
+ else:
263
+ val = {"connected": False, "state": "runtime_not_ready", "handle": None}
264
+ _x_cache.update(at=now, val=val)
265
+ return val
266
+
267
+
268
+ # ---- version (resolveVersion + latest release + semver), cached ------------
269
+ # Latest-version SOURCE = GitHub releases/latest first, npm only as a fallback.
270
+ # This mirrors mcp/src/version.ts::latestPublishedVersion and is load-bearing:
271
+ # the .mcpb boxes that render the menu bar have NO npm on PATH (PATH is just
272
+ # /usr/bin:/bin:/usr/sbin:/sbin), so an npm-only probe always yields latest=None
273
+ # there, update_available is always False, and the "S4L ⬆" banner can never
274
+ # fire on a box — even with a new release live. The 2026-07-01 v1.6.182 fix
275
+ # closed this in version.ts only; the menu bar computes its snapshot through
276
+ # THIS module (mcp/menubar/s4l_state.py tier 1, the loopback tier was removed
277
+ # to fix a UI freeze), so the same probe must live here too. curl is at
278
+ # /usr/bin/curl on every macOS PATH. GitHub releases/latest is also the SAME
279
+ # source the box updater installs from (s4l_box_update.sh / _mcpb_update_work),
280
+ # so "update available" and "what an update installs" cannot disagree.
281
+ #
282
+ # TTL is ~1 minute: a new release must surface in the menu bar within a minute.
283
+ # Probe order (measured 2026-07-01 releasing v1.6.188):
284
+ # 1. api.github.com releases/latest with a CONDITIONAL request (If-None-Match).
285
+ # The API reflects a new release near-instantly, and GitHub does NOT count
286
+ # 304 responses against the unauthenticated 60/h-per-IP quota, so a 1-min
287
+ # cadence is quota-free between releases (each new release costs one 200).
288
+ # A plain (unconditional) 1-min API poll would burn the whole quota.
289
+ # 2. The website redirect (github.com/.../releases/latest 302s to
290
+ # /releases/tag/vX.Y.Z): un-rate-limited fallback, but GitHub's web tier
291
+ # lagged the API by ~2 minutes after the release, so it is not primary.
292
+ # 3. npm (dev machines only; boxes have no npm).
293
+ #
294
+ # CHANNEL (2026-07-02): a box on the `staging` channel tracks the newest release
295
+ # OVERALL (prereleases included), resolved from the releases LIST endpoint,
296
+ # instead of releases/latest (which excludes prereleases). The `stable` path is
297
+ # byte-for-byte the historical behavior. Keep resolution + the rc-aware compare
298
+ # in lockstep with mcp/src/version.ts.
299
+ _ver_cache = {"at": 0.0, "latest": None, "tag": None, "channel": None, "checked": False}
300
+ _VER_TTL = 55.0
301
+
302
+ _RELEASES_LATEST_URL = "https://github.com/m13v/s4l/releases/latest"
303
+ _RELEASES_LATEST_API = "https://api.github.com/repos/m13v/s4l/releases/latest"
304
+ # Staging resolves the newest release OVERALL from the releases LIST, since
305
+ # releases/latest deliberately excludes prereleases.
306
+ _RELEASES_LIST_API = (
307
+ "https://api.github.com/repos/m13v/s4l/releases?per_page=30"
308
+ )
309
+
310
+
311
+ def _channel():
312
+ """Release channel for this box (stable|staging). Prefer the sibling
313
+ s4l_channel module; fall back to reading the marker directly so snapshot has
314
+ no hard import-order dependency. Unknown/absent = stable (fail-safe)."""
315
+ try:
316
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
317
+ import s4l_channel # noqa: E402
318
+ return s4l_channel.read_channel()
319
+ except Exception:
320
+ v = (_read_json(os.path.join(_state_dir(), "channel.json")) or {}).get("channel")
321
+ return v if v in ("stable", "staging") else "stable"
322
+
323
+
324
+ def _parse_semverish(v):
325
+ return v if v and v[0].isdigit() and v.count(".") >= 2 else None
326
+
327
+
328
+ def _latest_from_github_redirect():
329
+ # releases/latest already excludes drafts and prereleases. No -L: read the
330
+ # first response's Location via %{redirect_url} and stop.
331
+ try:
332
+ res = subprocess.run(
333
+ ["/usr/bin/curl", "-fsS", "-m", "10", "-o", "/dev/null",
334
+ "-w", "%{redirect_url}", _RELEASES_LATEST_URL],
335
+ capture_output=True, text=True, timeout=12)
336
+ loc = (res.stdout or "").strip()
337
+ if "/releases/tag/" not in loc:
338
+ return None
339
+ return _parse_semverish(loc.rsplit("/", 1)[-1].lstrip("v").strip())
340
+ except Exception:
341
+ return None
342
+
343
+
344
+ # In-process conditional-request state. Long-lived processes (the menu bar) send
345
+ # If-None-Match on every probe and get free 304s; short-lived shell-outs pay one
346
+ # 200 per process, which is rare enough to stay far under quota.
347
+ _api_state = {"etag": None, "latest": None}
348
+
349
+
350
+ def _latest_from_github_api():
351
+ try:
352
+ args = ["/usr/bin/curl", "-sS", "-m", "10",
353
+ "-H", "Accept: application/vnd.github+json"]
354
+ if _api_state["etag"]:
355
+ args += ["-H", "If-None-Match: %s" % _api_state["etag"]]
356
+ args += ["-w", "\n__CURL_STATUS__:%{http_code}\n__CURL_ETAG__:%header{etag}",
357
+ _RELEASES_LATEST_API]
358
+ res = subprocess.run(args, capture_output=True, text=True, timeout=12)
359
+ status, etag, body = 0, None, []
360
+ for line in (res.stdout or "").splitlines():
361
+ if line.startswith("__CURL_STATUS__:"):
362
+ status = int(line.split(":", 1)[1].strip() or 0)
363
+ elif line.startswith("__CURL_ETAG__:"):
364
+ etag = line.split(":", 1)[1].strip() or None
365
+ else:
366
+ body.append(line)
367
+ if status == 304:
368
+ return _api_state["latest"]
369
+ if status != 200:
370
+ return None
371
+ tag = (json.loads("\n".join(body)) or {}).get("tag_name")
372
+ v = _parse_semverish(tag.lstrip("v").strip()) if isinstance(tag, str) else None
373
+ if v:
374
+ _api_state.update(etag=etag, latest=v)
375
+ return v
376
+ except Exception:
377
+ return None
378
+
379
+
380
+ def _latest_from_github_list_staging():
381
+ """Staging channel: newest release OVERALL (prereleases included) from the
382
+ releases LIST endpoint. Returns (version, tag) or (None, None). Drafts are
383
+ skipped. 'Newest' is by the rc-aware key so 1.6.193 outranks 1.6.193-rc.N and
384
+ rc.2 outranks rc.1."""
385
+ try:
386
+ res = subprocess.run(
387
+ ["/usr/bin/curl", "-sS", "-m", "10",
388
+ "-H", "Accept: application/vnd.github+json",
389
+ _RELEASES_LIST_API],
390
+ capture_output=True, text=True, timeout=12)
391
+ rels = json.loads(res.stdout or "[]")
392
+ if not isinstance(rels, list):
393
+ return None, None
394
+ best_v, best_tag, best_key = None, None, None
395
+ for r in rels:
396
+ if not isinstance(r, dict) or r.get("draft"):
397
+ continue
398
+ tag = r.get("tag_name")
399
+ if not isinstance(tag, str):
400
+ continue
401
+ v = _parse_semverish(tag.lstrip("v").strip())
402
+ if not v:
403
+ continue
404
+ k = _ver_key(v)
405
+ if best_key is None or k > best_key:
406
+ best_v, best_tag, best_key = v, tag, k
407
+ return best_v, best_tag
408
+ except Exception:
409
+ return None, None
410
+
411
+
412
+ def _latest_from_npm():
413
+ try:
414
+ res = subprocess.run(["npm", "view", "social-autoposter", "version"],
415
+ capture_output=True, text=True, timeout=8)
416
+ line = (res.stdout.strip().splitlines() or [""])[-1].strip()
417
+ return line if line and line[0].isdigit() else None
418
+ except Exception:
419
+ return None
420
+
421
+
422
+ def _resolve_version() -> str:
423
+ for p in (
424
+ os.path.join(_repo_dir(), "mcp", "dist", "version.json"),
425
+ os.path.join(_repo_dir(), "package.json"),
426
+ os.path.join(_repo_dir(), "mcp", "package.json"),
427
+ ):
428
+ v = (_read_json(p) or {}).get("version")
429
+ if isinstance(v, str) and v:
430
+ return v
431
+ return "0.0.0-unknown"
432
+
433
+
434
+ def _latest_published(channel=None):
435
+ """(version, tag) for the newest release on this box's channel. The tag is
436
+ what the staging download URL is built from; stable callers can ignore it and
437
+ use releases/latest/download. Cached with the channel so a mid-process
438
+ channel flip re-probes instead of serving the other channel's cached value."""
439
+ if channel is None:
440
+ channel = _channel()
441
+ now = time.time()
442
+ # Cache failures (latest=None) too, like version.ts: a menu-bar tick loop
443
+ # re-probing an unreachable/rate-limited GitHub every few seconds would burn
444
+ # the unauthenticated API quota (60/h per IP) and lock itself out for good.
445
+ if (_ver_cache["checked"] and _ver_cache["channel"] == channel
446
+ and now - _ver_cache["at"] < _VER_TTL):
447
+ return _ver_cache["latest"], _ver_cache["tag"]
448
+ if channel == "staging":
449
+ latest, tag = _latest_from_github_list_staging()
450
+ # Fallback to the stable probes if the list endpoint fails, so a staging
451
+ # box degrades to "at least track stable" rather than going blind.
452
+ if latest is None:
453
+ latest = _latest_from_github_api() or _latest_from_github_redirect()
454
+ tag = ("v" + latest) if latest else None
455
+ else:
456
+ latest = _latest_from_github_api()
457
+ if latest is None:
458
+ latest = _latest_from_github_redirect()
459
+ if latest is None:
460
+ latest = _latest_from_npm()
461
+ tag = ("v" + latest) if latest else None
462
+ _ver_cache.update(at=now, latest=latest, tag=tag, channel=channel, checked=True)
463
+ return latest, tag
464
+
465
+
466
+ # Precedence key for an rc-aware semver compare, matching mcp/src/version.ts::
467
+ # verKey. A full release outranks any prerelease of the SAME core version
468
+ # (1.6.193 > 1.6.193-rc.2 > 1.6.193-rc.1). For stable (no prereleases ever
469
+ # compared) this reduces to a plain numeric core compare, so behavior there is
470
+ # unchanged.
471
+ def _ver_key(v):
472
+ import re
473
+ s = str(v).strip().lstrip("v")
474
+ core, _, pre = s.partition("-")
475
+ core = core.split("+", 1)[0]
476
+ nums = [int(x) if x.isdigit() else 0 for x in core.split(".")]
477
+ while len(nums) < 3:
478
+ nums.append(0)
479
+ if not pre:
480
+ return (nums[0], nums[1], nums[2], 1, 0)
481
+ m = re.findall(r"\d+", pre)
482
+ return (nums[0], nums[1], nums[2], 0, int(m[-1]) if m else 0)
483
+
484
+
485
+ def _is_newer(latest, current) -> bool:
486
+ return _ver_key(latest) > _ver_key(current)
487
+
488
+
489
+ # ---- onboarding ledger + live overlay --------------------------------------
490
+ def _onboarding_live(live_status):
491
+ led = _read_json(os.path.join(_state_dir(), "onboarding-progress.json")) or {}
492
+ ms = led.get("milestones")
493
+ # The ledger stores milestones as a dict id->record; the snapshot exposes a
494
+ # list. Mirror onboarding-ledger.cjs publicSnapshot() ordering via MILESTONES.
495
+ order = ["environment_checked", "runtime_ready", "x_connected", "profile_scanned",
496
+ "mode_chosen", "project_ready", "topics_seeded", "tasks_scheduled"]
497
+ out = []
498
+ if isinstance(ms, dict):
499
+ for mid in order:
500
+ rec = dict(ms.get(mid) or {"status": "pending", "attempts": 0})
501
+ rec["id"] = mid
502
+ if mid in live_status:
503
+ rec["status"] = live_status[mid]
504
+ out.append(rec)
505
+ elif isinstance(ms, list):
506
+ for rec in ms:
507
+ rec = dict(rec)
508
+ if rec.get("id") in live_status:
509
+ rec["status"] = live_status[rec["id"]]
510
+ out.append(rec)
511
+ result = dict(led)
512
+ result["milestones"] = out
513
+ result["complete"] = bool(out) and all(m.get("status") == "complete" for m in out)
514
+ return result
515
+
516
+
517
+ def compute() -> dict:
518
+ """Build the full snapshot dict (same shape as buildSnapshot())."""
519
+ projects = _projects()
520
+ rt_ready = _runtime_ready()
521
+ x = _x_status()
522
+ mode = _mode()
523
+ flags = _flags()
524
+ schedule_state = _schedule_state()
525
+ # Personal-brand-only setups have NO managed product project; the persona IS the
526
+ # draftable "project" for the self-promo lane. Surface it as a project row when
527
+ # that lane is on so projects_ready / setup_complete / project_ready reflect a
528
+ # persona-only setup instead of forever reading "not set up". (2026-06-30)
529
+ persona = _persona_status()
530
+ if persona is not None and flags.get("personal_brand"):
531
+ projects = projects + [persona]
532
+ any_ready = any(p["ready"] for p in projects)
533
+ setup_complete = rt_ready and any_ready and bool(x["connected"])
534
+
535
+ installed = _resolve_version()
536
+ channel = _channel()
537
+ latest, latest_tag = _latest_published(channel)
538
+ update_available = bool(latest) and _is_newer(latest, installed)
539
+
540
+ live_status = {
541
+ "runtime_ready": "complete" if rt_ready else "pending",
542
+ "x_connected": "complete" if x["connected"] else "pending",
543
+ "mode_chosen": "complete" if _mode_chosen() else "pending",
544
+ "project_ready": "complete" if any_ready else "pending",
545
+ "tasks_scheduled": "complete" if schedule_state == "ok" else "pending",
546
+ }
547
+
548
+ return {
549
+ "projects": projects,
550
+ "projects_total": len(projects),
551
+ "projects_ready": sum(1 for p in projects if p["ready"]),
552
+ "x_connected": bool(x["connected"]),
553
+ "x_state": x["state"] or "",
554
+ "x_handle": x["handle"],
555
+ "autopilot_on": _autopilot_on(),
556
+ "autopilot_stalled": setup_complete and _autopilot_stalled(),
557
+ "schedule_state": schedule_state,
558
+ "auto_update_on": _auto_update_on(),
559
+ "version": installed,
560
+ "latest_version": latest,
561
+ "latest_tag": latest_tag,
562
+ "channel": channel,
563
+ "update_available": update_available,
564
+ "runtime_ready": rt_ready,
565
+ "runtime_provisioning": _runtime_provisioning(),
566
+ "setup_complete": setup_complete,
567
+ "mode": mode,
568
+ "flags": _flags(),
569
+ "onboarding": _onboarding_live(live_status),
570
+ }
571
+
572
+
573
+ def main() -> int:
574
+ try:
575
+ print(json.dumps(compute()))
576
+ except Exception as e:
577
+ print(json.dumps({"_error": str(e)}))
578
+ return 1
579
+ return 0
580
+
581
+
582
+ if __name__ == "__main__":
583
+ sys.exit(main())