@m13v/s4l 1.7.4-rc.4 → 1.7.4

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 (86) hide show
  1. package/README.md +134 -101
  2. package/bin/cli.js +23 -0
  3. package/mcp/dist/index.js +24 -5
  4. package/mcp/dist/runtime.js +110 -0
  5. package/mcp/dist/version.js +163 -54
  6. package/mcp/dist/version.json +2 -2
  7. package/mcp/manifest.json +1 -1
  8. package/mcp/menubar/s4l_card.py +55 -19
  9. package/mcp/menubar/s4l_menubar.py +179 -56
  10. package/mcp/menubar/s4l_state.py +41 -1
  11. package/mcp/package.json +1 -1
  12. package/package.json +4 -2
  13. package/scripts/active_experiments.py +30 -6
  14. package/scripts/autopilot_stall_watch.py +185 -63
  15. package/scripts/cdp_ready_check.py +94 -0
  16. package/scripts/engage_twitter_helper.py +83 -17
  17. package/scripts/engagement_styles.py +221 -26
  18. package/scripts/enrich_reply_parents.py +68 -22
  19. package/scripts/feedback_digest.py +24 -1
  20. package/scripts/generate_daily_human_style.py +31 -7
  21. package/scripts/invent_styles.py +103 -26
  22. package/scripts/linkedin_cadence.py +234 -0
  23. package/scripts/linkedin_killswitch.py +43 -0
  24. package/scripts/memory_snapshot.py +57 -0
  25. package/scripts/patches/browser-harness-loopback-cdp-proxy.patch +118 -0
  26. package/scripts/recent_self_posts.py +6 -0
  27. package/scripts/reddit_browser.py +57 -3
  28. package/scripts/s4l_box_update.sh +20 -1
  29. package/scripts/s4l_mode.py +3 -4
  30. package/scripts/scan_x_profile.py +25 -7
  31. package/scripts/schedule_state.py +23 -0
  32. package/scripts/scheduled_task_selfheal.py +213 -16
  33. package/scripts/setup_twitter_auth.py +6 -1
  34. package/scripts/snapshot.py +179 -61
  35. package/scripts/stall_guard.py +70 -0
  36. package/scripts/test_scheduled_task_selfheal.py +121 -0
  37. package/scripts/top_performers.py +34 -0
  38. package/scripts/twitter_browser.py +146 -8
  39. package/scripts/twitter_post_plan.py +10 -1
  40. package/scripts/twitter_scan.py +26 -0
  41. package/scripts/watchdog_hung_runs.py +117 -3
  42. package/skill/amplitude-24h-signups.sh +1 -1
  43. package/skill/audit-dm-staleness.sh +1 -1
  44. package/skill/audit-linkedin.sh +1 -1
  45. package/skill/audit-reddit-resurrect.sh +1 -1
  46. package/skill/audit.sh +5 -5
  47. package/skill/check-web-chats.sh +1 -1
  48. package/skill/dm-outreach-linkedin.sh +4 -4
  49. package/skill/dm-outreach-reddit.sh +1 -1
  50. package/skill/dm-outreach-twitter.sh +1 -1
  51. package/skill/engage-dm-replies.sh +16 -3
  52. package/skill/engage-linkedin.sh +2 -2
  53. package/skill/engage-moltbook.sh +1 -1
  54. package/skill/engage-twitter.sh +4 -4
  55. package/skill/github-engage.sh +1 -1
  56. package/skill/lib/browser-launch.sh +54 -0
  57. package/skill/lib/linkedin-backend.sh +22 -15
  58. package/skill/lib/reddit-backend.sh +17 -11
  59. package/skill/lib/twitter-backend.sh +137 -16
  60. package/skill/link-edit-github.sh +1 -1
  61. package/skill/link-edit-moltbook.sh +1 -1
  62. package/skill/link-edit-reddit.sh +1 -1
  63. package/skill/linkedin-cadence.sh +20 -0
  64. package/skill/linkedin-presence.sh +1 -1
  65. package/skill/lock.sh +30 -6
  66. package/skill/precompute-stats.sh +1 -1
  67. package/skill/refresh-instagram-tokens.sh +1 -1
  68. package/skill/refresh-twitter-following.sh +1 -1
  69. package/skill/run-generate-daily-style.sh +1 -1
  70. package/skill/run-instagram-daily.sh +1 -1
  71. package/skill/run-instagram-render.sh +1 -1
  72. package/skill/run-linkedin-launchd.sh +1 -1
  73. package/skill/run-linkedin-unipile.sh +1 -1
  74. package/skill/run-linkedin.sh +1 -1
  75. package/skill/run-reddit-search.sh +2 -2
  76. package/skill/run-reddit-threads.sh +1 -1
  77. package/skill/run-twitter-cycle.sh +190 -55
  78. package/skill/scan-instagram-replies.sh +1 -1
  79. package/skill/sentry-digest.sh +1 -1
  80. package/skill/stats-instagram.sh +1 -1
  81. package/skill/stats-linkedin.sh +2 -2
  82. package/skill/stats.sh +1 -1
  83. package/skill/sweep-link-clicks.sh +1 -1
  84. package/scripts/_lock_preempt_test.py +0 -60
  85. package/scripts/saps_activity.py +0 -18
  86. package/scripts/saps_mode.py +0 -18
@@ -83,9 +83,10 @@ sys.stderr = _TimestampedStderr(sys.stderr)
83
83
  # best-effort: a missing repo path or SDK degrades to a silent no-op.
84
84
  _sentry = None
85
85
  try:
86
- # Read the repo dir from the plist env; this read runs
87
- # BEFORE scripts/ is on sys.path, so the s4l_env mirror can't help yet.
88
- _repo = os.environ.get("S4L_REPO_DIR")
86
+ # Read the repo dir from the plist env. This runs BEFORE scripts/ is on
87
+ # sys.path, so the s4l_env mirror cannot help yet; the old key is accepted
88
+ # only at this bootstrap seam so pre-rename plists can find the mirror.
89
+ _repo = os.environ.get("S4L_REPO_DIR") or os.environ.get("SAPS_REPO_DIR")
89
90
  if _repo:
90
91
  _scripts = os.path.join(_repo, "scripts")
91
92
  if _scripts not in sys.path:
@@ -337,9 +338,9 @@ UPDATE_PROMPT = "Update the S4L plugin to the latest version."
337
338
  # Re-arm goes through the HOST create_scheduled_task path (the same one onboarding
338
339
  # uses) — it registers the routines under whatever account is logged in and shows
339
340
  # up in Routines. The host tool only runs inside an agent chat, so the menu bar
340
- # hands Claude this prompt (auto-typed, clipboard+paste fallback). We do NOT write
341
- # scheduled-tasks.json directly — that can't reliably target a just-switched-into
342
- # account, which is exactly the bug it caused.
341
+ # hands Claude this prompt (auto-typed, clipboard+paste fallback). The automatic
342
+ # fix can now direct-write after resolving lastActiveOrg; keep this host path for
343
+ # Keychain denial, missing/stale cookie, and other resolution failures.
343
344
  REARM_PROMPT = (
344
345
  "Set up the S4L draft autopilot schedule for this Claude account. "
345
346
  "If queue_setup is available, call it; then for s4l-worker call the host tool "
@@ -398,32 +399,33 @@ DIAGNOSE_PROMPT_TEMPLATE = (
398
399
  "<that file>` so the report reaches the S4L developers. Keep replies short."
399
400
  )
400
401
 
401
- # A pending draft job older than this (seconds) with nothing claiming it means no
402
- # routine is draining the queue — the worker would claim within a minute if it
403
- # were firing. False-positive-free: an idle queue has no pending job at all, so a
404
- # quiet pipeline (no candidates) never trips this. Comfortably above the host
405
- # scheduler's per-minute cadence + a slow claim.
406
- AUTOPILOT_STALL_SECONDS = 180
407
-
408
- # A job CLAIMED but never finished (sits in running/ this long) means a worker
409
- # picked it up and then wedged mid-run (the claude -p drafting child died / never
410
- # spawned). Generous enough that the longest real drafting turn never trips it.
411
- # Keep in sync with RUNNING_STALL_SECONDS (scripts/autopilot_stall_watch.py).
412
- AUTOPILOT_RUNNING_STALL_SECONDS = 900
413
-
414
- # The "firing" window (how fresh lastRunAt must be) lives in the single source of
415
- # truth, scripts/schedule_state.py (FIRING_WINDOW there). _schedule_state delegates
416
- # to it, so it is intentionally NOT redefined here.
417
-
418
- # How long the producer can sit narrating "draft Nm" before we treat
419
- # the draft as STUCK rather than healthy. The producer writes that label the whole
420
- # time it blocks waiting for a worker to return a result (up to its 30-min queue
421
- # timeout). A healthy drain clears in ~1-2 min; if the label has been "drafting"
422
- # this long, the worker keeps dying mid-run (host inactivity-kill) or never claims,
423
- # and nothing is draining — so we flip the menu bar from a reassuring spinner to
424
- # ⚠ instead of letting the stale "drafting (8m)" lie persist. Well above any
425
- # healthy single drain (the worker itself dies at ~2 min today).
426
- DRAFT_STUCK_SECONDS = 300
402
+ # Stall thresholds live in the single source of truth, scripts/schedule_state.py
403
+ # (HEALTHY_DRAIN_MAX_SECONDS and the names derived from it) — retune there, not
404
+ # here. Same module that owns FIRING_WINDOW; _schedule_state already delegates to
405
+ # it. The three names cover three distinct failure modes:
406
+ # AUTOPILOT_STALL_SECONDS pending job unclaimed → no routine draining
407
+ # (fix: Re-arm). Idle queue has no pending job,
408
+ # so a quiet pipeline never trips it.
409
+ # AUTOPILOT_RUNNING_STALL_SECONDS job claimed but wedged in running/ → worker
410
+ # died mid-run (fix: Diagnose).
411
+ # DRAFT_STUCK_SECONDS the producer's "draft Nm" label alive this
412
+ # long → end-to-end clock; the only timer that
413
+ # spans claim→die→requeue churn loops, which
414
+ # reset the two per-state timers on every
415
+ # transition while nothing ever completes.
416
+ # The import is best-effort because the S4L_REPO_DIR sys.path insertion near the
417
+ # top of this file is best-effort; the fallback literal mirrors
418
+ # HEALTHY_DRAIN_MAX_SECONDS.
419
+ try:
420
+ from schedule_state import ( # noqa: E402
421
+ STALL_SECONDS as AUTOPILOT_STALL_SECONDS,
422
+ RUNNING_STALL_SECONDS as AUTOPILOT_RUNNING_STALL_SECONDS,
423
+ DRAFT_STUCK_SECONDS,
424
+ )
425
+ except Exception:
426
+ AUTOPILOT_STALL_SECONDS = 1200
427
+ AUTOPILOT_RUNNING_STALL_SECONDS = 1200
428
+ DRAFT_STUCK_SECONDS = 1200
427
429
 
428
430
  # A poll can read a transient "not stuck" moment (e.g. the activity file
429
431
  # between one dying worker attempt and the next claim on the same aged job)
@@ -463,6 +465,14 @@ REVIEW_UNATTENDED_SENTRY_SECONDS = 3600.0
463
465
  REVIEW_SNOOZE_SECONDS = float(os.environ.get("S4L_REVIEW_SNOOZE_S", "3600"))
464
466
  SNOOZE_FILE = os.path.join(st.state_dir(), "review-snooze.json")
465
467
 
468
+ # Reveal cadence (2026-07-13): how often FRESH draft cards may pop, independent
469
+ # of how often the pipeline drafts (mode.json `reveal_cadence_secs`, default 1h,
470
+ # 0 = as soon as ready). Only fresh presentations are paced: an already-open
471
+ # card still grows live via extend_active, snooze still parks everything, and
472
+ # the menu's "Review N pending drafts" always bypasses. The last-presented
473
+ # epoch is persisted so a menubar restart mid-interval doesn't re-pop early.
474
+ REVEAL_STAMP_FILE = os.path.join(st.state_dir(), "review-last-presented.json")
475
+
466
476
 
467
477
  def _label_elapsed_secs(label):
468
478
  """Parse the trailing duration the producer encodes in a drafting activity
@@ -547,6 +557,9 @@ class S4LMenuBar(rumps.App):
547
557
  # (0 = not snoozed). Loaded from disk so a menubar restart mid-snooze
548
558
  # doesn't re-pop the card the user just put away.
549
559
  self._review_snooze_until = self._read_snooze_until()
560
+ # Reveal cadence: when the last fresh card presentation happened (epoch,
561
+ # 0 = never). Loaded from disk so a restart mid-interval doesn't re-pop.
562
+ self._last_presented_at = self._read_last_presented()
550
563
  # Unattended-review watchdog state (_maybe_heal_review).
551
564
  self._review_heal_at = 0.0
552
565
  self._review_unattended_notified = False
@@ -786,8 +799,8 @@ class S4LMenuBar(rumps.App):
786
799
  bridge is panel-only), so the reliable path here is: copy the prompt to the
787
800
  clipboard, open Claude, and tell the user to paste it. (The dashboard
788
801
  widget's button does this in one click via app.sendMessage — no paste.) We
789
- do NOT auto-type (focus/timing flaky) and do NOT write the registry directly
790
- (can't reliably target a just-switched-into account).
802
+ do NOT auto-type (focus/timing flaky). This remains the fallback when the
803
+ automatic direct-write path cannot resolve/decrypt the active org cookie.
791
804
 
792
805
  The click itself is captured (mirroring _diagnose_fix) — previously this
793
806
  button had NO telemetry at all, so a past incident (Karol, 2026-07-07)
@@ -810,15 +823,15 @@ class S4LMenuBar(rumps.App):
810
823
 
811
824
  def _finish_schedule_setup(self, _=None):
812
825
  """One-click fix for schedule_state == 'missing' when
813
- scheduled_task_selfheal.can_create_for_active_account() confirms a
814
- session directory already exists for the active account (2026-07-08):
815
- quit Claude, create the registration via a direct file write (the
816
- same heal() the update flow uses — see its module docstring for why
817
- this is equivalent to what create_scheduled_task would produce), then
818
- relaunch. Primary action for this case now instead of re-arm: no
819
- clipboard paste, no chat turn required. Re-arm remains the fallback
820
- for the rarer case where no session directory exists yet for the
821
- active account (see _build_menu) — fix 5 never fabricates one.
826
+ scheduled_task_selfheal.can_create_for_active_account() confirms an
827
+ existing session directory OR the active-org cookie needed to make
828
+ the exact account/org directory: quit Claude, create the registration
829
+ via a direct file write (the same heal() the update flow uses — see
830
+ its module docstring for why this is equivalent to what
831
+ create_scheduled_task would produce), then relaunch. Primary action
832
+ for this case now instead of re-arm: no clipboard paste or chat turn.
833
+ Re-arm remains the fallback when the cookie is unavailable or macOS
834
+ denies its Safe Storage Keychain read.
822
835
 
823
836
  No confirm dialog: the restart is already disclosed in the menu item
824
837
  label itself ("...(restarts Claude)", _build_menu) — a modal repeating
@@ -898,7 +911,7 @@ class S4LMenuBar(rumps.App):
898
911
  to see between cycles) -> the ⚠ stays on continuously instead of
899
912
  flickering off. This is the durable signal.
900
913
  (2) FAST: a draft job has sat unclaimed in pending/ past
901
- AUTOPILOT_STALL_SECONDS -> catches a fresh stall ~3 min in, before
914
+ AUTOPILOT_STALL_SECONDS -> catches a fresh stall ~20 min in, before
902
915
  the first full producer timeout has even latched (1).
903
916
  (3) IN-FLIGHT: a draft job was claimed (moved to running/) but never
904
917
  finished within AUTOPILOT_RUNNING_STALL_SECONDS -> the worker picked
@@ -1127,6 +1140,40 @@ class S4LMenuBar(rumps.App):
1127
1140
  """Menu callback shim: rumps passes the clicked MenuItem last."""
1128
1141
  self._set_split(share)
1129
1142
 
1143
+ # Reveal-cadence presets (seconds between fresh card pop-ups; 0 = as soon
1144
+ # as drafts are ready). Manual mode.json edits can set any value; the
1145
+ # checkmark then lands on an exact match or none, same as the lane split.
1146
+ REVEAL_CADENCE_PRESETS = (
1147
+ (0, "Immediately"),
1148
+ (1800, "Every 30 minutes"),
1149
+ (3600, "Every hour"),
1150
+ (14400, "Every 4 hours"),
1151
+ )
1152
+
1153
+ def _cadence_label(self, secs=None):
1154
+ """Human label for a cadence value, for menu copy."""
1155
+ if secs is None:
1156
+ secs = st.read_reveal_cadence()
1157
+ for preset, label in self.REVEAL_CADENCE_PRESETS:
1158
+ if round(secs) == preset:
1159
+ return label
1160
+ m = round(secs / 60)
1161
+ return f"Every {m} min" if m else "Immediately"
1162
+
1163
+ def _on_cadence_preset(self, secs, _sender=None):
1164
+ """Menu callback shim: rumps passes the clicked MenuItem last."""
1165
+ written = st.write_reveal_cadence(secs)
1166
+ self._notify(
1167
+ "S4L draft cards",
1168
+ f"New draft cards will show: {self._cadence_label(written).lower()}",
1169
+ )
1170
+ self._sig = None
1171
+ try:
1172
+ self._tick(None)
1173
+ except Exception as e:
1174
+ sys.stderr.write(f"[s4l-menubar] cadence rebuild failed: {e}\n")
1175
+ sys.stderr.flush()
1176
+
1130
1177
  def _set_split(self, share):
1131
1178
  """Set the personal-brand share (pure local mode.json write, same
1132
1179
  contract as _toggle_lane) and rebuild so the checkmark moves instantly."""
@@ -2400,11 +2447,12 @@ class S4LMenuBar(rumps.App):
2400
2447
  self._stall_reason_info = ("", "")
2401
2448
  # Draft worker stuck/killed: the producer narrates "drafting replies (Nm)"
2402
2449
  # the whole time it blocks waiting for a worker to return a result, with NO
2403
- # idea the worker died. A healthy drain clears in ~1-2 min; once that label
2404
- # has been "drafting" past DRAFT_STUCK_SECONDS the worker keeps getting
2405
- # killed mid-run (or never claims) and nothing is draining — flip to ⚠
2406
- # instead of leaving the reassuring "drafting (8m)" spinner up. Skip when a
2407
- # more specific cause (rate limit) already owns the reason.
2450
+ # idea the worker died. A healthy drain can run 8-10 min end to end (slot
2451
+ # wait + the drafting turn); once that label has been "drafting" past
2452
+ # DRAFT_STUCK_SECONDS the worker keeps getting killed mid-run (or never
2453
+ # claims) and nothing is draining — flip to ⚠ instead of leaving the
2454
+ # reassuring "drafting (25m)" spinner up. Skip when a more specific cause
2455
+ # (rate limit) already owns the reason.
2408
2456
  #
2409
2457
  # Gated on schedule_state in ("ok", "stalled"), NOT missing/disabled: when
2410
2458
  # the schedule is missing/disabled (e.g. orphaned by an account switch),
@@ -2625,6 +2673,11 @@ class S4LMenuBar(rumps.App):
2625
2673
  int(self._review_snooze_until)
2626
2674
  if time.time() < self._review_snooze_until
2627
2675
  else 0,
2676
+ # Reveal cadence: rebuilds when the preset changes (checkmark +
2677
+ # submenu title) and when a hold starts or lapses (the "Next cards
2678
+ # around HH:MM" line in _state_c).
2679
+ round(st.read_reveal_cadence()),
2680
+ int(self._reveal_hold_until(pending_count)),
2628
2681
  )
2629
2682
  if sig != self._sig:
2630
2683
  self._sig = sig
@@ -2723,6 +2776,37 @@ class S4LMenuBar(rumps.App):
2723
2776
  except Exception:
2724
2777
  pass
2725
2778
 
2779
+ def _read_last_presented(self):
2780
+ try:
2781
+ with open(REVEAL_STAMP_FILE) as f:
2782
+ return float((json.load(f) or {}).get("at") or 0.0)
2783
+ except Exception:
2784
+ return 0.0
2785
+
2786
+ def _stamp_presented(self):
2787
+ """Record a fresh card presentation, mirrored to disk (restart-safe).
2788
+ Starts the next reveal-cadence interval."""
2789
+ self._last_presented_at = time.time()
2790
+ try:
2791
+ with open(REVEAL_STAMP_FILE, "w") as f:
2792
+ json.dump({"at": self._last_presented_at}, f)
2793
+ except Exception:
2794
+ pass
2795
+
2796
+ def _reveal_hold_until(self, pending_count):
2797
+ """Epoch until which the reveal cadence is actually holding fresh cards
2798
+ back (0.0 = no hold). A hold only exists while there is something to
2799
+ hold: pending drafts, no review in flight, and not snoozed (the snooze
2800
+ label and gate take precedence)."""
2801
+ if pending_count <= 0 or self._review_active:
2802
+ return 0.0
2803
+ now = time.time()
2804
+ if now < self._review_snooze_until:
2805
+ return 0.0
2806
+ cadence = st.read_reveal_cadence()
2807
+ until = self._last_presented_at + cadence
2808
+ return until if (cadence > 0 and now < until) else 0.0
2809
+
2726
2810
  def _review_now(self, _=None):
2727
2811
  """Menu: bring the pending draft cards up right now. Clears any snooze;
2728
2812
  this is the one presentation path that may take keyboard focus (the
@@ -2804,6 +2888,17 @@ class S4LMenuBar(rumps.App):
2804
2888
  sys.stderr.flush()
2805
2889
  self._last_review_sig = sig
2806
2890
  return
2891
+ # Reveal cadence: hold FRESH pop-ups until the configured interval since
2892
+ # the last presentation has passed (0 = show immediately). Drafting is
2893
+ # untouched; the backlog keeps accumulating and is presented whole once
2894
+ # due. Deliberately after the extend-open-card path above (an on-screen
2895
+ # card keeps growing live) and skipped for focus=True (the menu's
2896
+ # "Review N pending drafts" is an explicit ask). _last_review_sig is NOT
2897
+ # stamped on a hold, so the same set still presents fresh when due.
2898
+ if not focus:
2899
+ cadence = st.read_reveal_cadence()
2900
+ if cadence > 0 and time.time() - self._last_presented_at < cadence:
2901
+ return
2807
2902
  with self._review_lock:
2808
2903
  self._reset_posting_progress_locked()
2809
2904
  self._review_active = True
@@ -2827,6 +2922,7 @@ class S4LMenuBar(rumps.App):
2827
2922
  # Record as shown only AFTER the cards are actually up, so a transient
2828
2923
  # card-UI failure never permanently suppresses this pending set.
2829
2924
  self._last_review_sig = sig
2925
+ self._stamp_presented()
2830
2926
  # No macOS notification for fresh drafts, per explicit user
2831
2927
  # request (2026-07-03): the card itself is the surface. A missed
2832
2928
  # card is the unattended watchdog's job (it heals the window and
@@ -3331,13 +3427,12 @@ class S4LMenuBar(rumps.App):
3331
3427
  items.append(rumps.MenuItem("Set up draft schedule for this account", callback=self._rearm))
3332
3428
  else:
3333
3429
  items.append(self._label("⚠ Draft tasks aren’t scheduled on this account"))
3334
- # Prefer the automatic fix (2026-07-08): if the active account
3335
- # already has a session directory, _finish_schedule_setup can
3336
- # create the registration directly (heal() fix 5) with no
3337
- # clipboard paste needed. Fall back to re-arm only when fix 5
3338
- # has nowhere to write (no session dir yet for this account —
3339
- # it never fabricates one), since that's the one case where
3340
- # only the live create_scheduled_task host tool can help.
3430
+ # Prefer the automatic fix: an existing session directory OR
3431
+ # lastActiveOrg cookie lets _finish_schedule_setup target the
3432
+ # active account exactly (heal() fix 5), with no clipboard
3433
+ # paste. Fall back to the live create_scheduled_task host tool
3434
+ # when neither source is available; decryption denial after a
3435
+ # click is also caught by the post-restart verification.
3341
3436
  can_selfheal = False
3342
3437
  try:
3343
3438
  import scheduled_task_selfheal
@@ -3416,6 +3511,22 @@ class S4LMenuBar(rumps.App):
3416
3511
  split_menu.add(it)
3417
3512
  items.append(split_menu)
3418
3513
 
3514
+ # Reveal cadence: how often fresh draft cards may pop (the drafting
3515
+ # pipeline is unchanged; this only paces the pop-up). Always offered:
3516
+ # it is the "don't interrupt me every few minutes" control.
3517
+ cadence = st.read_reveal_cadence()
3518
+ cadence_menu = rumps.MenuItem(
3519
+ f"Show draft cards: {self._cadence_label(cadence).lower()}"
3520
+ )
3521
+ for preset, label in self.REVEAL_CADENCE_PRESETS:
3522
+ it = rumps.MenuItem(
3523
+ label,
3524
+ callback=functools.partial(self._on_cadence_preset, preset),
3525
+ )
3526
+ it.state = 1 if round(cadence) == preset else 0
3527
+ cadence_menu.add(it)
3528
+ items.append(cadence_menu)
3529
+
3419
3530
  items.append(rumps.separator)
3420
3531
  items.append(rumps.MenuItem("Open dashboard", callback=self._open_dashboard))
3421
3532
  # The one entry point for overall feedback (the review card no longer
@@ -3554,6 +3665,18 @@ class S4LMenuBar(rumps.App):
3554
3665
  )
3555
3666
  )
3556
3667
  )
3668
+ else:
3669
+ # Reveal-cadence hold: the backlog exists but the pop-up is being
3670
+ # paced. Same courtesy line as the snooze; "Review N pending
3671
+ # drafts" above shows them right now regardless.
3672
+ hold_until = self._reveal_hold_until(pending_count)
3673
+ if hold_until:
3674
+ items.append(
3675
+ self._label(
3676
+ "Next cards around "
3677
+ + time.strftime("%H:%M", time.localtime(hold_until))
3678
+ )
3679
+ )
3557
3680
  # No bulk-discard item here anymore: "Discard all…" lives in the
3558
3681
  # review card's title bar (2026-07-10). Reaching it with no card open
3559
3682
  # is "Review N pending drafts" -> Discard all, two clicks.
@@ -31,7 +31,7 @@ from pathlib import Path
31
31
  # The repo dir must be read tolerantly INLINE (old name included) because the
32
32
  # mirror module itself lives in $REPO/scripts and isn't importable before the
33
33
  # sys.path insertion below. Best-effort: failure degrades to defaults.
34
- _repo_for_env = os.environ.get("S4L_REPO_DIR")
34
+ _repo_for_env = os.environ.get("S4L_REPO_DIR") or os.environ.get("SAPS_REPO_DIR")
35
35
  if _repo_for_env:
36
36
  _scripts_for_env = os.path.join(_repo_for_env, "scripts")
37
37
  if _scripts_for_env not in sys.path:
@@ -1251,6 +1251,46 @@ def write_split(share):
1251
1251
  return share
1252
1252
 
1253
1253
 
1254
+ # Draft-card reveal cadence: minimum seconds between FRESH card pop-ups
1255
+ # (0 = present as soon as drafts are ready). Drafting itself is untouched;
1256
+ # drafts keep accumulating in the review store and the menubar just paces the
1257
+ # pop-up. Lives in mode.json (every writer preserves unknown keys). Presets
1258
+ # are the menubar's REVEAL_CADENCE_PRESETS; any non-negative value is valid.
1259
+ REVEAL_CADENCE_DEFAULT = 3600.0
1260
+
1261
+
1262
+ def read_reveal_cadence():
1263
+ """Seconds between draft-card reveals (0 = immediately, default 1 hour)."""
1264
+ d = read_json(MODE_FILE)
1265
+ try:
1266
+ secs = float(d.get("reveal_cadence_secs"))
1267
+ except (TypeError, ValueError, AttributeError):
1268
+ return REVEAL_CADENCE_DEFAULT
1269
+ return max(0.0, secs)
1270
+
1271
+
1272
+ def write_reveal_cadence(secs):
1273
+ """Persist the reveal cadence, preserving every other mode.json key.
1274
+ Returns the written value. Never raises (a menu click must not crash)."""
1275
+ try:
1276
+ secs = max(0.0, float(secs))
1277
+ except (TypeError, ValueError):
1278
+ return read_reveal_cadence()
1279
+ try:
1280
+ payload = read_json(MODE_FILE)
1281
+ if not isinstance(payload, dict):
1282
+ payload = {}
1283
+ payload["reveal_cadence_secs"] = secs
1284
+ p = Path(state_dir()) / MODE_FILE
1285
+ p.parent.mkdir(parents=True, exist_ok=True)
1286
+ tmp = p.with_suffix(".json.tmp")
1287
+ tmp.write_text(json.dumps(payload))
1288
+ os.replace(str(tmp), str(p))
1289
+ except Exception:
1290
+ pass
1291
+ return secs
1292
+
1293
+
1254
1294
  def write_mode(mode):
1255
1295
  """Legacy single-mode setter: named lane ON, the other OFF (compat)."""
1256
1296
  if mode not in _VALID_MODES:
package/mcp/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m13v/s4l-mcp",
3
- "version": "1.7.4-rc.4",
3
+ "version": "1.7.4",
4
4
  "private": true,
5
5
  "description": "Desktop MCP client for social-autoposter (X/Twitter rail): manual draft/review/approve loop, autopilot control, and stats. Thin wrapper over the existing pipeline scripts.",
6
6
  "license": "MIT",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m13v/s4l",
3
- "version": "1.7.4-rc.4",
3
+ "version": "1.7.4",
4
4
  "description": "Automated social posting pipeline for Reddit, X/Twitter, LinkedIn, and Moltbook. Install as a Claude Code agent skill.",
5
5
  "bin": {
6
6
  "social-autoposter": "bin/cli.js",
@@ -17,6 +17,7 @@
17
17
  "!bin/server.js",
18
18
  "!bin/auth.js",
19
19
  "scripts/*.py",
20
+ "scripts/patches/",
20
21
  "!scripts/db_direct.py",
21
22
  "!scripts/sync_blocked_authors.py",
22
23
  "!scripts/backfill_real_clicks.py",
@@ -56,6 +57,7 @@
56
57
  "!scripts/_serp_report.py",
57
58
  "!scripts/_serp_vs_gsc_report.py",
58
59
  "!scripts/_test_since_hook.py",
60
+ "!scripts/_lock_preempt_test.py",
59
61
  "!scripts/add_deploy_metadata.py",
60
62
  "!scripts/add_nightowl_to_config.py",
61
63
  "!scripts/amplitude_user_lookup.py",
@@ -160,4 +162,4 @@
160
162
  "pg": "^8.20.0",
161
163
  "ws": "^8.0.0"
162
164
  }
163
- }
165
+ }
@@ -40,16 +40,39 @@ ENV_PREFIX = "S4L_EXP_"
40
40
  # Keep draft_prompt entries in sync with bin/server.js
41
41
  # DRAFT_PROMPT_VARIANT_DEFS and the arm strings in run-twitter-cycle.sh.
42
42
  DESCRIPTIONS = {
43
+ "draft_b_source": {
44
+ "human_derived": (
45
+ "Draft B explore slot: style distilled from real top-performing "
46
+ "human replies (daily synthesizer), least-used first"
47
+ ),
48
+ "model_invented": (
49
+ "Draft B explore slot: freshly invented style from the "
50
+ "standalone invention job (post 2026-07-10), least-used first"
51
+ ),
52
+ "scored_fallback": (
53
+ "Draft B explore pool was empty; fell back to a second scored "
54
+ "pick from the proven-style pool"
55
+ ),
56
+ },
43
57
  "draft_prompt": {
58
+ "treatment_v3": (
59
+ "style-as-form: the assigned style is the binding FORM (defining "
60
+ "move + per-style length + self-check), learned preferences apply "
61
+ "inside it; keeps the v2 skeleton ban"
62
+ ),
63
+ "control_v3": (
64
+ "plain draft directive, uniform length clamp, no structure ban"
65
+ ),
66
+ # v2 arms (skeleton-ban) retired 2026-07-10; v1 arms
67
+ # (decoupled-product-pivot) retired 2026-07-06; kept so any
68
+ # straggler card from an old plan still explains itself.
44
69
  "treatment_v2": (
45
- "skeleton ban: forbids the concede-then-reverse "
70
+ "v2, retired: skeleton ban, forbids the concede-then-reverse "
46
71
  '"easy X / hard Y" structure and forces varied entry points'
47
72
  ),
48
73
  "control_v2": (
49
- "current draft directive (style + product pivot), no structure ban"
74
+ "v2, retired: draft directive (style + product pivot), no structure ban"
50
75
  ),
51
- # v1 arms (decoupled-product-pivot) retired 2026-07-06; kept so any
52
- # straggler card from an old plan still explains itself.
53
76
  "treatment": "v1, retired: product pivot decoupled from the reply",
54
77
  "control": "v1, retired: original draft directive",
55
78
  },
@@ -99,8 +122,9 @@ def collect(env=None):
99
122
  if var.startswith(ENV_PREFIX) and (v or "").strip():
100
123
  out[var[len(ENV_PREFIX):].lower()] = v.strip()
101
124
  # 2026-07-06: the personal_brand persona directive is now ARM-AWARE in
102
- # run-twitter-cycle.sh (treatment_v2 adds the concede-then-reverse skeleton ban,
103
- # control_v2 does not), so the assigned draft_prompt arm DOES touch persona
125
+ # run-twitter-cycle.sh (treatment_v3 adds the skeleton ban + two-layer
126
+ # style/preferences contract, control_v3 does not), so the assigned
127
+ # draft_prompt arm DOES touch persona
104
128
  # drafts. Keep it stamped so the arm surfaces on persona cards and the per-arm
105
129
  # readout covers both lanes. (Previously dropped here because the persona
106
130
  # directive overrode both arms wholesale; that is no longer the case.)