@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.
- package/README.md +143 -0
- package/SKILL.md +342 -0
- package/bin/cli.js +980 -0
- package/bin/cookie-helper.js +315 -0
- package/bin/platform.js +59 -0
- package/bin/scheduler/index.js +12 -0
- package/bin/scheduler/launchd.js +518 -0
- package/browser-agent-configs/all-agents-mcp.json +68 -0
- package/browser-agent-configs/linkedin-agent-mcp.json +16 -0
- package/browser-agent-configs/linkedin-agent.json +17 -0
- package/browser-agent-configs/linkedin-harness-mcp.json +21 -0
- package/browser-agent-configs/reddit-agent-mcp.json +16 -0
- package/browser-agent-configs/reddit-agent.json +17 -0
- package/browser-agent-configs/twitter-harness-mcp.json +18 -0
- package/config.example.json +45 -0
- package/mcp/dist/index.js +4212 -0
- package/mcp/dist/onboarding.js +200 -0
- package/mcp/dist/panel.html +176 -0
- package/mcp/dist/product-link.html +102 -0
- package/mcp/dist/repo.js +222 -0
- package/mcp/dist/runtime.js +1079 -0
- package/mcp/dist/screencast.js +323 -0
- package/mcp/dist/setup.js +545 -0
- package/mcp/dist/telemetry.js +306 -0
- package/mcp/dist/twitterAuth.js +138 -0
- package/mcp/dist/version.js +271 -0
- package/mcp/dist/version.json +4 -0
- package/mcp/install-runtime.mjs +70 -0
- package/mcp/install.mjs +169 -0
- package/mcp/manifest.json +80 -0
- package/mcp/menubar/dashboard_server.py +213 -0
- package/mcp/menubar/s4l_card.py +1314 -0
- package/mcp/menubar/s4l_log_relay.py +179 -0
- package/mcp/menubar/s4l_menubar.py +2439 -0
- package/mcp/menubar/s4l_state.py +891 -0
- package/mcp/package.json +34 -0
- package/mcp/shared/doctor.cjs +437 -0
- package/mcp/shared/onboarding-ledger.cjs +324 -0
- package/mcp-servers/browser-harness/server.py +968 -0
- package/package.json +160 -0
- package/requirements.txt +20 -0
- package/scripts/_compute_allowlist.py +58 -0
- package/scripts/_db_update.py +20 -0
- package/scripts/_filt.py +9 -0
- package/scripts/_li_notif_match.py +76 -0
- package/scripts/_li_notif_orchestrate.py +126 -0
- package/scripts/_lock_preempt_test.py +60 -0
- package/scripts/_run_icp_precheck.py +57 -0
- package/scripts/a16z_pearx_calendar_reminders.py +99 -0
- package/scripts/account_resolver.py +141 -0
- package/scripts/active_campaigns.py +114 -0
- package/scripts/active_users.py +190 -0
- package/scripts/amplitude_24h_signups.py +468 -0
- package/scripts/amplitude_signups.py +177 -0
- package/scripts/apply_onboarding_selections.py +131 -0
- package/scripts/audience_pages.py +243 -0
- package/scripts/audit_helper.py +120 -0
- package/scripts/author_history_block.py +353 -0
- package/scripts/autopilot_stall_watch.py +284 -0
- package/scripts/backfill_twitter_attempts_topic.py +81 -0
- package/scripts/backfill_twitter_log_post_no_id.py +322 -0
- package/scripts/bench_dashboard.sh +138 -0
- package/scripts/bh_send.py +39 -0
- package/scripts/build_persona.py +409 -0
- package/scripts/bulk_icp.py +18 -0
- package/scripts/campaign_bump.py +51 -0
- package/scripts/capture_thread_media.py +288 -0
- package/scripts/check_browser_lock_health.sh +81 -0
- package/scripts/check_external_pool_depth.py +253 -0
- package/scripts/check_unread_web_chats.py +28 -0
- package/scripts/claim_web_chat.py +47 -0
- package/scripts/classify_run_error.py +158 -0
- package/scripts/claude_job.py +988 -0
- package/scripts/clean_stale_singleton.sh +56 -0
- package/scripts/cleanup_harness_tabs.py +68 -0
- package/scripts/copy_browser_cookies.py +454 -0
- package/scripts/counterparty_history.py +350 -0
- package/scripts/db.py +57 -0
- package/scripts/discover_claude_profiles.py +120 -0
- package/scripts/discover_linkedin_candidates.py +984 -0
- package/scripts/dm_conversation.py +682 -0
- package/scripts/dm_db_update.py +69 -0
- package/scripts/dm_engage_helper.py +161 -0
- package/scripts/dm_outreach_helper.py +147 -0
- package/scripts/dm_outreach_twitter_helper.py +129 -0
- package/scripts/dm_send_log.py +106 -0
- package/scripts/dm_short_links.py +1084 -0
- package/scripts/dump_web_chat_history.py +47 -0
- package/scripts/engage_github.py +640 -0
- package/scripts/engage_reddit.py +1235 -0
- package/scripts/engage_twitter_helper.py +301 -0
- package/scripts/engagement_styles.py +1787 -0
- package/scripts/enrich_twitter_candidates.py +82 -0
- package/scripts/feedback_digest.py +448 -0
- package/scripts/fetch_prospect_profile.py +312 -0
- package/scripts/fetch_twitter_t1.py +134 -0
- package/scripts/find_threads.py +530 -0
- package/scripts/follow_gate_log.py +59 -0
- package/scripts/funnel_per_day.py +194 -0
- package/scripts/generate_daily_human_style.py +494 -0
- package/scripts/generation_trace.py +173 -0
- package/scripts/get_run_cost.py +107 -0
- package/scripts/github_engage_helper.py +93 -0
- package/scripts/github_tools.py +509 -0
- package/scripts/harness_overlay.py +556 -0
- package/scripts/harvest_twitter_following.py +243 -0
- package/scripts/heartbeat.sh +70 -0
- package/scripts/history_context.py +284 -0
- package/scripts/http_api.py +206 -0
- package/scripts/human_dm_replies_helper.py +169 -0
- package/scripts/identity.py +302 -0
- package/scripts/ig_batch_creator.sh +93 -0
- package/scripts/ig_post_type_picker.py +243 -0
- package/scripts/ig_scrape_transcribe.sh +91 -0
- package/scripts/ingest_human_dm_replies.py +271 -0
- package/scripts/ingest_web_chat_replies.py +229 -0
- package/scripts/install_fleet.py +187 -0
- package/scripts/invent_mcp_server.py +350 -0
- package/scripts/invent_topics.py +1462 -0
- package/scripts/learned_preferences.py +263 -0
- package/scripts/li_discovery.py +161 -0
- package/scripts/link_edit_helper.py +142 -0
- package/scripts/link_tail.py +592 -0
- package/scripts/linkedin_api.py +561 -0
- package/scripts/linkedin_browser.py +730 -0
- package/scripts/linkedin_cooldown.py +128 -0
- package/scripts/linkedin_exclusions.py +234 -0
- package/scripts/linkedin_killswitch.py +1333 -0
- package/scripts/linkedin_search_topic_schema.py +49 -0
- package/scripts/linkedin_unipile.py +658 -0
- package/scripts/linkedin_url.py +228 -0
- package/scripts/log_claude_session.py +636 -0
- package/scripts/log_draft.py +143 -0
- package/scripts/log_linkedin_search_attempts.py +126 -0
- package/scripts/log_post.py +651 -0
- package/scripts/log_run.py +364 -0
- package/scripts/log_thread_media.py +108 -0
- package/scripts/log_twitter_search_attempts.py +150 -0
- package/scripts/log_twitter_skips.py +211 -0
- package/scripts/lookup_post.py +78 -0
- package/scripts/mark_web_chat_processed.py +32 -0
- package/scripts/mcp_lock_proxy.py +370 -0
- package/scripts/memory_snapshot.py +972 -0
- package/scripts/merge_review_queue.py +215 -0
- package/scripts/mint_external_pool.py +182 -0
- package/scripts/mint_kent_pool.py +249 -0
- package/scripts/moltbook_post.py +320 -0
- package/scripts/moltbook_tools.py +159 -0
- package/scripts/pending_threads.py +188 -0
- package/scripts/pick_ig_account.py +177 -0
- package/scripts/pick_project.py +208 -0
- package/scripts/pick_search_topic.py +771 -0
- package/scripts/pick_thread_target.py +279 -0
- package/scripts/pick_twitter_thread_target.py +202 -0
- package/scripts/podlog_fetch_batch.sh +32 -0
- package/scripts/post_github.py +1311 -0
- package/scripts/post_reddit.py +2668 -0
- package/scripts/precompute_dashboard_stats.py +204 -0
- package/scripts/preflight.sh +297 -0
- package/scripts/progress.py +88 -0
- package/scripts/project_excludes.py +353 -0
- package/scripts/project_slugs.py +91 -0
- package/scripts/project_stats.py +241 -0
- package/scripts/project_stats_json.py +1563 -0
- package/scripts/project_topics.py +192 -0
- package/scripts/qualified_query_bank.py +436 -0
- package/scripts/reap_stale_claude_sessions.py +867 -0
- package/scripts/reddit_browser.py +2549 -0
- package/scripts/reddit_browser_fetch.py +141 -0
- package/scripts/reddit_browser_lock.py +593 -0
- package/scripts/reddit_chat_sync.py +710 -0
- package/scripts/reddit_query_bank.py +200 -0
- package/scripts/reddit_threads_helper.py +151 -0
- package/scripts/reddit_tools.py +956 -0
- package/scripts/refresh_instagram_tokens.py +280 -0
- package/scripts/release-mcpb.sh +497 -0
- package/scripts/reply_db.py +334 -0
- package/scripts/reply_insert.py +98 -0
- package/scripts/reply_risk_digest.py +761 -0
- package/scripts/reset-test-machine.sh +602 -0
- package/scripts/restore_twitter_session.py +177 -0
- package/scripts/ripen_reddit_plan.py +478 -0
- package/scripts/run_claude.sh +433 -0
- package/scripts/run_moltbook_cycle.py +555 -0
- package/scripts/s4l_box_update.sh +226 -0
- package/scripts/s4l_channel.py +103 -0
- package/scripts/s4l_ctl.sh +75 -0
- package/scripts/s4l_env.py +47 -0
- package/scripts/saps_activity.py +126 -0
- package/scripts/saps_mode.py +328 -0
- package/scripts/scan_dm_candidates.py +580 -0
- package/scripts/scan_github_replies.py +168 -0
- package/scripts/scan_instagram_comments.py +481 -0
- package/scripts/scan_moltbook_replies.py +252 -0
- package/scripts/scan_pii.py +190 -0
- package/scripts/scan_reddit_replies.py +377 -0
- package/scripts/scan_twitter_mentions_browser.py +327 -0
- package/scripts/scan_twitter_thread_followups.py +299 -0
- package/scripts/scan_x_profile.py +384 -0
- package/scripts/schedule_state.py +202 -0
- package/scripts/scheduled_tasks_snapshot.py +123 -0
- package/scripts/score_linkedin_candidates.py +419 -0
- package/scripts/score_twitter_candidates.py +718 -0
- package/scripts/scrape_linkedin_comment_stats.py +1755 -0
- package/scripts/scrape_linkedin_stats_browser.py +52 -0
- package/scripts/scrape_reddit_views.py +365 -0
- package/scripts/seed_search_queries.py +453 -0
- package/scripts/seed_search_topics.py +127 -0
- package/scripts/send_web_chat_reply.py +130 -0
- package/scripts/sentry_init.py +128 -0
- package/scripts/setup_twitter_auth.py +1320 -0
- package/scripts/snapshot.py +583 -0
- package/scripts/stats.py +2702 -0
- package/scripts/stats_helper.py +52 -0
- package/scripts/strike_alert.py +783 -0
- package/scripts/sweep_post_link_clicks.py +107 -0
- package/scripts/sync_ig_to_posts.py +147 -0
- package/scripts/test_browser_lock.py +189 -0
- package/scripts/test_installation_api.sh +52 -0
- package/scripts/test_percard_posting.py +142 -0
- package/scripts/top_dud_linkedin_queries.py +71 -0
- package/scripts/top_dud_reddit_queries.py +67 -0
- package/scripts/top_dud_twitter_queries.py +71 -0
- package/scripts/top_dud_twitter_topics.py +102 -0
- package/scripts/top_linkedin_queries.py +55 -0
- package/scripts/top_omitted_reddit_topics.py +91 -0
- package/scripts/top_performers.py +588 -0
- package/scripts/top_search_topics.py +180 -0
- package/scripts/top_twitter_queries.py +190 -0
- package/scripts/twitter_access_check.py +382 -0
- package/scripts/twitter_account.py +41 -0
- package/scripts/twitter_batch_phase.py +126 -0
- package/scripts/twitter_browser.py +2804 -0
- package/scripts/twitter_cookie_mirror.py +130 -0
- package/scripts/twitter_cycle_helper.py +310 -0
- package/scripts/twitter_gen_links.py +287 -0
- package/scripts/twitter_post_plan.py +1188 -0
- package/scripts/twitter_scan.py +324 -0
- package/scripts/twitter_supply_signal.py +57 -0
- package/scripts/twitter_threads_helper.py +152 -0
- package/scripts/unclaim_web_chat.py +29 -0
- package/scripts/update_instagram_stats.py +261 -0
- package/scripts/update_linkedin_stats_from_feed.py +328 -0
- package/scripts/version.py +72 -0
- package/scripts/watchdog_hung_runs.py +343 -0
- package/scripts/write_generation_trace.py +73 -0
- package/setup/SKILL.md +277 -0
- package/skill/amplitude-24h-signups.sh +38 -0
- package/skill/archive-old-logs.sh +40 -0
- package/skill/audit-dm-staleness.sh +42 -0
- package/skill/audit-linkedin.sh +14 -0
- package/skill/audit-moltbook.sh +4 -0
- package/skill/audit-reddit-resurrect.sh +67 -0
- package/skill/audit-reddit.sh +4 -0
- package/skill/audit-twitter.sh +4 -0
- package/skill/audit.sh +287 -0
- package/skill/backfill-twitter-attempts-topic.sh +19 -0
- package/skill/backfill-twitter-ghost-posts.sh +24 -0
- package/skill/check-external-pool-depth.sh +7 -0
- package/skill/check-web-chats.sh +203 -0
- package/skill/dm-outreach-linkedin.sh +250 -0
- package/skill/dm-outreach-reddit.sh +274 -0
- package/skill/dm-outreach-twitter.sh +265 -0
- package/skill/engage-dm-replies-linkedin.sh +4 -0
- package/skill/engage-dm-replies-reddit.sh +4 -0
- package/skill/engage-dm-replies-twitter.sh +4 -0
- package/skill/engage-dm-replies.sh +1597 -0
- package/skill/engage-linkedin.sh +581 -0
- package/skill/engage-moltbook.sh +36 -0
- package/skill/engage-reddit.sh +146 -0
- package/skill/engage-twitter.sh +467 -0
- package/skill/github-engage.sh +176 -0
- package/skill/ingest-web-chat-replies.sh +38 -0
- package/skill/invent-supply-test.sh +100 -0
- package/skill/invent-topics.sh +50 -0
- package/skill/lib/linkedin-backend.sh +364 -0
- package/skill/lib/platform.sh +48 -0
- package/skill/lib/reddit-backend.sh +234 -0
- package/skill/lib/twitter-backend.sh +314 -0
- package/skill/link-edit-github.sh +136 -0
- package/skill/link-edit-moltbook.sh +117 -0
- package/skill/link-edit-reddit.sh +201 -0
- package/skill/linkedin-presence.sh +182 -0
- package/skill/linkedin-recovery.sh +282 -0
- package/skill/lock.sh +647 -0
- package/skill/memory-snapshot.sh +39 -0
- package/skill/precompute-stats.sh +35 -0
- package/skill/prewarm-funnel.sh +104 -0
- package/skill/refresh-instagram-tokens.sh +57 -0
- package/skill/refresh-twitter-following.sh +52 -0
- package/skill/reply-risk-digest.sh +31 -0
- package/skill/run-cycle-update-guard.sh +44 -0
- package/skill/run-draft-and-publish.sh +123 -0
- package/skill/run-generate-daily-style.sh +50 -0
- package/skill/run-github-launchd.sh +62 -0
- package/skill/run-github.sh +102 -0
- package/skill/run-instagram-daily.sh +149 -0
- package/skill/run-instagram-render.sh +875 -0
- package/skill/run-linkedin-launchd.sh +81 -0
- package/skill/run-linkedin-unipile.sh +130 -0
- package/skill/run-linkedin.sh +1593 -0
- package/skill/run-moltbook-launchd.sh +61 -0
- package/skill/run-moltbook.sh +38 -0
- package/skill/run-overlay-watch.sh +100 -0
- package/skill/run-reddit-search-launchd.sh +64 -0
- package/skill/run-reddit-search.sh +505 -0
- package/skill/run-reddit-threads-double.sh +32 -0
- package/skill/run-reddit-threads.sh +847 -0
- package/skill/run-scan-moltbook-replies.sh +57 -0
- package/skill/run-twitter-cycle-launchd.sh +63 -0
- package/skill/run-twitter-cycle-singleton.sh +62 -0
- package/skill/run-twitter-cycle.sh +2408 -0
- package/skill/run-twitter-threads.sh +592 -0
- package/skill/scan-instagram-replies.sh +61 -0
- package/skill/scan-twitter-followups.sh +57 -0
- package/skill/social-autoposter-update.sh +66 -0
- package/skill/stats-instagram.sh +72 -0
- package/skill/stats-linkedin.sh +271 -0
- package/skill/stats-moltbook.sh +4 -0
- package/skill/stats-reddit.sh +4 -0
- package/skill/stats-twitter.sh +4 -0
- package/skill/stats.sh +521 -0
- package/skill/strike-alert.sh +18 -0
- package/skill/styles.sh +87 -0
- package/skill/sweep-link-clicks.sh +40 -0
- 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())
|