@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
package/skill/lock.sh CHANGED
@@ -121,18 +121,42 @@ if [ -z "${_SA_LOCK_DIRS+x}" ]; then
121
121
  # is defect "owner=OTHER" (wipes a live peer's lock -> double-hold).
122
122
  if _sa_we_own_lock "$d"; then
123
123
  _sa_lock_event trap_rm "$_lname" "$(_sa_lock_owner_tag "$d")"
124
- echo "[lock] trap-released $_lname pid=$$ at $(date +%H:%M:%S)" >&2
124
+ echo "[lock] trap-released $_lname pid=$$ at $(date -u +%Y-%m-%dT%H:%M:%SZ)" >&2
125
125
  rm -rf "$d"
126
126
  else
127
127
  _sa_lock_event trap_rm_skipped "$_lname" "$(_sa_lock_owner_tag "$d")"
128
- echo "[lock] trap-release SKIPPED $_lname pid=$$ (not owner) at $(date +%H:%M:%S)" >&2
128
+ echo "[lock] trap-release SKIPPED $_lname pid=$$ (not owner) at $(date -u +%Y-%m-%dT%H:%M:%SZ)" >&2
129
129
  fi
130
130
  done
131
131
  for t in ${_SA_LOCK_TICKETS[@]+"${_SA_LOCK_TICKETS[@]}"}; do
132
132
  rm -f "$t"
133
133
  done
134
134
  }
135
- trap _sa_release_locks EXIT INT TERM HUP
135
+ trap _sa_release_locks EXIT
136
+ # 2026-07-12: INT/TERM/HUP release locks AND EXIT, instead of sharing the
137
+ # plain EXIT handler. Scenario this fixes (observed 2026-07-12 12:02:09,
138
+ # engage-dm-replies.sh pid 72465): a stray signal fired the old combined
139
+ # trap, which released BOTH held locks and then RESUMED the script (bash
140
+ # continues after a trapped signal when the handler doesn't exit). The run
141
+ # kept driving the shared twitter Chrome without its twitter-browser lock,
142
+ # the twitter cycle legitimately acquired the freed lock and navigated the
143
+ # same tab mid-send, and all 3 send-dm attempts died with
144
+ # thread_url_redirected ("shared-tab drift", human_dm_replies id 100045).
145
+ # A signaled run must never continue lock-protected work: log WHICH signal
146
+ # hit (the old single-line event gave no clue), clean up once, exit 128+N.
147
+ # Scripts that install their own combined traps after sourcing this file
148
+ # (e.g. run-twitter-cycle.sh's _sa_combined_exit) still override these.
149
+ _sa_exit_on_signal() {
150
+ local _sig="$1" _code="$2"
151
+ _sa_lock_event signal_exit "-" "signal=$_sig"
152
+ echo "[lock] caught SIG${_sig} pid=$$; releasing locks and exiting $_code at $(date -u +%Y-%m-%dT%H:%M:%SZ)" >&2
153
+ _sa_release_locks
154
+ trap - EXIT
155
+ exit "$_code"
156
+ }
157
+ trap '_sa_exit_on_signal INT 130' INT
158
+ trap '_sa_exit_on_signal TERM 143' TERM
159
+ trap '_sa_exit_on_signal HUP 129' HUP
136
160
  fi
137
161
 
138
162
  acquire_lock() {
@@ -223,7 +247,7 @@ acquire_lock() {
223
247
  [ "$_existing" != "$ticket_file" ] && _new_t+=("$_existing")
224
248
  done
225
249
  _SA_LOCK_TICKETS=(${_new_t[@]+"${_new_t[@]}"})
226
- echo "[lock] acquired $name pid=$$ at $(date +%H:%M:%S) waited=${waited}s" >&2
250
+ echo "[lock] acquired $name pid=$$ at $(date -u +%Y-%m-%dT%H:%M:%SZ) waited=${waited}s" >&2
227
251
  _sa_lock_event acquired "$name" "waited=${waited}s"
228
252
  break
229
253
  fi
@@ -670,10 +694,10 @@ release_lock() {
670
694
  if _sa_we_own_lock "$lock_dir"; then
671
695
  _sa_lock_event release "$name" "$(_sa_lock_owner_tag "$lock_dir")"
672
696
  rm -rf "$lock_dir"
673
- echo "[lock] released $name pid=$$ at $(date +%H:%M:%S)" >&2
697
+ echo "[lock] released $name pid=$$ at $(date -u +%Y-%m-%dT%H:%M:%SZ)" >&2
674
698
  else
675
699
  _sa_lock_event release_skipped "$name" "$(_sa_lock_owner_tag "$lock_dir")"
676
- echo "[lock] release SKIPPED $name pid=$$ (not owner) at $(date +%H:%M:%S)" >&2
700
+ echo "[lock] release SKIPPED $name pid=$$ (not owner) at $(date -u +%Y-%m-%dT%H:%M:%SZ)" >&2
677
701
  fi
678
702
  # Rebuild the lock stack without this entry so the EXIT trap doesn't try to
679
703
  # rm it again (harmless, but keeps the stack honest if release_lock is paired
@@ -31,5 +31,5 @@ python3 "$REPO_DIR/scripts/precompute_dashboard_stats.py"
31
31
  EXIT_CODE=$?
32
32
  RUN_ELAPSED=$(( $(date +%s) - RUN_START ))
33
33
 
34
- echo "[$(date +%H:%M:%S)] === done in ${RUN_ELAPSED}s (exit=${EXIT_CODE}) ==="
34
+ echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] === done in ${RUN_ELAPSED}s (exit=${EXIT_CODE}) ==="
35
35
  exit "$EXIT_CODE"
@@ -19,7 +19,7 @@ LOG_DIR="$REPO_DIR/skill/logs"
19
19
  mkdir -p "$LOG_DIR"
20
20
  LOG_FILE="$LOG_DIR/refresh-instagram-tokens-$(date +%Y-%m-%d_%H%M%S).log"
21
21
 
22
- log() { echo "[$(date +%H:%M:%S)] $*" | tee -a "$LOG_FILE"; }
22
+ log() { echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] $*" | tee -a "$LOG_FILE"; }
23
23
  log "=== refresh-instagram-tokens fire: $(date) ==="
24
24
 
25
25
  RUN_START=$(date +%s)
@@ -16,7 +16,7 @@ set -uo pipefail
16
16
  LOG_DIR="$HOME/social-autoposter/skill/logs"
17
17
  mkdir -p "$LOG_DIR"
18
18
  LOG_FILE="$LOG_DIR/refresh-twitter-following-$(date +%Y-%m-%d_%H%M%S).log"
19
- log() { echo "[$(date +%H:%M:%S)] $*" | tee -a "$LOG_FILE"; }
19
+ log() { echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] $*" | tee -a "$LOG_FILE"; }
20
20
 
21
21
  REPO_DIR="$HOME/social-autoposter"
22
22
 
@@ -36,7 +36,7 @@ if [ -f "$REPO_DIR/.env" ]; then
36
36
  set +a
37
37
  fi
38
38
 
39
- log() { echo "[$(date +%H:%M:%S)] $*" | tee -a "$LOG_FILE"; }
39
+ log() { echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] $*" | tee -a "$LOG_FILE"; }
40
40
 
41
41
  log "starting daily human-style synthesizer"
42
42
 
@@ -36,7 +36,7 @@ if [ -f "$REPO_DIR/.env" ]; then
36
36
  set +a
37
37
  fi
38
38
 
39
- log() { echo "[$(date +%H:%M:%S)] $*" | tee -a "$LOG_FILE"; }
39
+ log() { echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] $*" | tee -a "$LOG_FILE"; }
40
40
 
41
41
  # Run accounting for dashboard Job History (Post Threads · Instagram).
42
42
  # Each exit site updates POSTED_CT / SKIPPED_CT / FAILED_CT; the EXIT trap
@@ -43,7 +43,7 @@ if [ -f "$REPO_DIR/.env" ]; then
43
43
  set +a
44
44
  fi
45
45
 
46
- log() { echo "[$(date +%H:%M:%S)] $*" | tee -a "$LOG_FILE"; }
46
+ log() { echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] $*" | tee -a "$LOG_FILE"; }
47
47
 
48
48
  # Run accounting for dashboard Job History (Render · Instagram). Matches the
49
49
  # pattern in run-instagram-daily.sh / run-twitter-threads.sh / run-reddit-threads.sh:
@@ -40,7 +40,7 @@ mkdir -p "$LOG_DIR"
40
40
  # State: ~/.claude/social-autoposter/linkedin.killswitch
41
41
  # Clear: python3 ~/social-autoposter/scripts/linkedin_killswitch.py clear
42
42
  if [ -f "$HOME/.claude/social-autoposter/linkedin.killswitch" ]; then
43
- echo "[$(date +%H:%M:%S)] LINKEDIN_KILLSWITCH active. Skipping run-linkedin." \
43
+ echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] LINKEDIN_KILLSWITCH active. Skipping run-linkedin." \
44
44
  >> "$LOG_DIR/launchd-linkedin-stderr.log"
45
45
  exit 0
46
46
  fi
@@ -45,7 +45,7 @@ SOCIAL_ID=""
45
45
  REPLY_TO=""
46
46
  DO_POST=0
47
47
 
48
- log() { echo "[$(date +%H:%M:%S)] $*" | tee -a "$LOG_FILE"; }
48
+ log() { echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] $*" | tee -a "$LOG_FILE"; }
49
49
 
50
50
  while [ $# -gt 0 ]; do
51
51
  case "$1" in
@@ -85,7 +85,7 @@ LINKEDIN_BACKEND="${LINKEDIN_BACKEND:-browser}"
85
85
  # Clear: python3 ~/social-autoposter/scripts/linkedin_killswitch.py clear
86
86
  # Only gates the browser backend — UniPile has no headed session to compromise.
87
87
  if [ "$LINKEDIN_BACKEND" = "browser" ] && [ -f "$HOME/.claude/social-autoposter/linkedin.killswitch" ]; then
88
- echo "[$(date +%H:%M:%S)] LINKEDIN_KILLSWITCH active. Aborting LinkedIn pipeline."
88
+ echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] LINKEDIN_KILLSWITCH active. Aborting LinkedIn pipeline."
89
89
  echo " Re-auth LinkedIn in harness Chrome, then: python3 ~/social-autoposter/scripts/linkedin_killswitch.py clear"
90
90
  exit 0
91
91
  fi
@@ -43,7 +43,7 @@ LOG_DIR="$REPO_DIR/skill/logs"
43
43
  mkdir -p "$LOG_DIR"
44
44
  LOG_FILE="$LOG_DIR/run-reddit-search-$(date +%Y-%m-%d_%H%M%S).log"
45
45
 
46
- log() { echo "[$(date +%H:%M:%S)] $*" | tee -a "$LOG_FILE"; }
46
+ log() { echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] $*" | tee -a "$LOG_FILE"; }
47
47
 
48
48
  log "=== Reddit Search Post Run: $(date) ==="
49
49
 
@@ -88,7 +88,7 @@ _parse_post_results() {
88
88
  # Bug observed 2026-05-08: a leaked "[14:57:04] Post phase: ..." line
89
89
  # produced "TOTAL_POSTED + [14:57:04]: syntax error" and `set -e` aborted
90
90
  # the script BEFORE the discover lane ran.
91
- echo "[$(date +%H:%M:%S)] Post phase: exit code $rc; counting as failed." >> "$LOG_FILE"
91
+ echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] Post phase: exit code $rc; counting as failed." >> "$LOG_FILE"
92
92
  echo "0 1"
93
93
  fi
94
94
  }
@@ -33,7 +33,7 @@ RUN_START_EPOCH=$(date +%s)
33
33
  # calls added in 5e41d96 (2026-05-10) fall through to macOS /usr/bin/log,
34
34
  # which barfs `Unknown subcommand` and ERR-traps the run at exit=64 before
35
35
  # the browser lease is ever acquired (silent kill since 2026-05-10).
36
- log() { echo "[$(date +%H:%M:%S)] $*" | tee -a "$LOG_FILE"; }
36
+ log() { echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] $*" | tee -a "$LOG_FILE"; }
37
37
 
38
38
  # Diagnostic: log the failing line and command before set -e kills the script.
39
39
  # Without this, silent deaths (e.g., Claude exits non-zero inside the $() below)
@@ -56,6 +56,7 @@ ulimit -n 4096 2>/dev/null || true
56
56
  # below (sourced libs + child scripts inherit it), so this one line fixes the
57
57
  # whole cycle's repo resolution on a bare .mcpb install.
58
58
  REPO_DIR="${S4L_REPO_DIR:-$HOME/social-autoposter}"
59
+ export REPO_DIR
59
60
  SKILL_FILE="$REPO_DIR/SKILL.md"
60
61
  LOG_DIR="$REPO_DIR/skill/logs"
61
62
  mkdir -p "$LOG_DIR"
@@ -173,7 +174,7 @@ if [ -f "$REPO_DIR/.env" ]; then
173
174
  set +a
174
175
  fi
175
176
 
176
- log() { echo "[$(date +%H:%M:%S)] $*" | tee -a "$LOG_FILE"; }
177
+ log() { echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] $*" | tee -a "$LOG_FILE"; }
177
178
 
178
179
  log "=== Twitter Cycle (batch=$BATCH_ID): $(date) ==="
179
180
  log "Logic=D (no-ripen + 1h freshness + 2k_view_cap; experiment concluded 2026-05-31); discover_freshness=${FRESHNESS_HOURS_DISCOVER}h"
@@ -502,7 +503,7 @@ python3 "$REPO_DIR/scripts/twitter_batch_phase.py" advance "$BATCH_ID" --phase p
502
503
  _PJ_ERR="$(mktemp)"
503
504
  PROJECTS_JSON=$(python3 - 2>"$_PJ_ERR" <<'PY'
504
505
  import json, os, subprocess, sys
505
- REPO = os.path.expanduser('~/social-autoposter')
506
+ REPO = os.path.expanduser(os.environ.get('S4L_REPO_DIR') or os.environ.get('REPO_DIR') or '~/social-autoposter')
506
507
  sys.path.insert(0, os.path.join(REPO, 'scripts'))
507
508
  import project_excludes as pe
508
509
 
@@ -882,6 +883,33 @@ print(cd//60, cons, cd, max(0, now-fs))' "$_GATE_FILE" "$_NOW" 2>/dev/null || ec
882
883
  release_lock "twitter-browser" 2>/dev/null || true
883
884
  exit 1
884
885
  fi
886
+ # Probe-error guard (2026-07-12, S4L-4H): output lacking a "gated" verdict
887
+ # means the probe CRASHED (typically the CDP attach failed or timed out on a
888
+ # wedged Chrome) rather than returning a clean bill. The old code logged
889
+ # 'Pre-flight access OK: {"success": false, ...}' and marched a wedged browser
890
+ # into the scan. Heal once (ensure_twitter_browser_for_backend now does a real
891
+ # CDP readiness check and restarts a wedged harness Chrome), re-probe, and end
892
+ # the cycle if there is still no verdict.
893
+ if [ -z "$_ACCESS_OUT" ] || ! printf '%s' "$_ACCESS_OUT" | grep -q '"gated"'; then
894
+ log " Pre-flight access probe FAILED (no verdict; browser not driveable): $(printf '%s' "$_ACCESS_OUT" | tr '\n' ' ' | tr -s ' ' | sed 's/^ *//')"
895
+ echo "twitter_cdp_wedge: access_probe_no_verdict" >&2
896
+ ensure_twitter_browser_for_backend || true
897
+ _ACCESS_OUT=$(TWITTER_CDP_URL="${TWITTER_CDP_URL:-http://127.0.0.1:9555}" \
898
+ python3 "$REPO_DIR/scripts/twitter_access_check.py" --session-probe --wait-ms 12000 2>/dev/null)
899
+ if [ -z "$_ACCESS_OUT" ] || ! printf '%s' "$_ACCESS_OUT" | grep -q '"gated"'; then
900
+ log " Pre-flight access probe still has no verdict after harness heal; ending cycle."
901
+ echo "twitter_batches: ended $BATCH_ID"
902
+ release_lock "twitter-browser" 2>/dev/null || true
903
+ exit 1
904
+ fi
905
+ if printf '%s' "$_ACCESS_OUT" | grep -q '"gated": *true'; then
906
+ log " Recovered probe reports an access gate; ending cycle (backoff engages on the next firing)."
907
+ echo "twitter_batches: ended $BATCH_ID"
908
+ release_lock "twitter-browser" 2>/dev/null || true
909
+ exit 1
910
+ fi
911
+ log " Access probe recovered after harness heal."
912
+ fi
885
913
  # Probe came back clean. If a backoff marker exists we were gated: record the
886
914
  # recovery (how long the gate lasted, since first_seen) BEFORE deleting it, so
887
915
  # the lift event + duration survive in the log even though the marker is gone.
@@ -977,7 +1005,8 @@ if [ "$SCAN_ATTEMPT" -gt 1 ]; then
977
1005
  rm -f "$UNIVERSE_EXHAUSTED_MARKER"
978
1006
  PROJECTS_JSON=$(python3 - "$PROJECTS_JSON" "$TRIED_TOPICS_JSON" "$UNIVERSE_EXHAUSTED_MARKER" <<'PY' 2>>"$LOG_FILE"
979
1007
  import json, os, sys
980
- sys.path.insert(0, os.path.expanduser('~/social-autoposter/scripts'))
1008
+ REPO = os.path.expanduser(os.environ.get('S4L_REPO_DIR') or os.environ.get('REPO_DIR') or '~/social-autoposter')
1009
+ sys.path.insert(0, os.path.join(REPO, 'scripts'))
981
1010
  from pick_search_topic import pick_topic_for_project, PickerError, UniverseExhaustedError
982
1011
 
983
1012
  projects = json.loads(sys.argv[1] or '[]')
@@ -1349,7 +1378,8 @@ fi
1349
1378
  # search_attempts log so a Phase 1 abort still leaves the marks behind.
1350
1379
  python3 - "$PROJECTS_JSON" <<'PY' 2>&1 | tee -a "$LOG_FILE" || true
1351
1380
  import json, os, sys
1352
- sys.path.insert(0, os.path.expanduser('~/social-autoposter/scripts'))
1381
+ REPO = os.path.expanduser(os.environ.get('S4L_REPO_DIR') or os.environ.get('REPO_DIR') or '~/social-autoposter')
1382
+ sys.path.insert(0, os.path.join(REPO, 'scripts'))
1353
1383
  import project_excludes as pe
1354
1384
  projects = json.loads(sys.argv[1] or '[]')
1355
1385
  total = 0
@@ -1643,9 +1673,10 @@ done <<< "$CANDIDATES"
1643
1673
 
1644
1674
  ALL_PROJECTS_JSON=$(python3 -c "
1645
1675
  import json, os, sys
1646
- sys.path.insert(0, os.path.expanduser('~/social-autoposter/scripts'))
1676
+ repo_dir = os.path.expanduser(os.environ.get('S4L_REPO_DIR') or os.environ.get('REPO_DIR') or '~/social-autoposter')
1677
+ sys.path.insert(0, os.path.join(repo_dir, 'scripts'))
1647
1678
  import learned_preferences as lp
1648
- config = json.load(open(os.path.expanduser('~/social-autoposter/config.json')))
1679
+ config = json.load(open(os.path.join(repo_dir, 'config.json')))
1649
1680
  projects = config.get('projects', [])
1650
1681
  # learned_preferences is a SINGLE install-wide block since 2026-07-08 (see
1651
1682
  # scripts/learned_preferences.py), not one per project. Computed once here
@@ -1725,12 +1756,33 @@ log "Engagement style assigned: mode=$PICKED_MODE style=${PICKED_STYLE:-(invent)
1725
1756
  # are actually different (a same-name pair defeats the whole point); INVENT
1726
1757
  # mode on either side is accepted immediately since an invented name is
1727
1758
  # definitionally distinct from a pinned one. This is orthogonal to the
1728
- # treatment_v2/control_v2 draft-prompt A/B below (that varies WORDING of the
1759
+ # treatment_v3/control_v3 draft-prompt A/B below (that varies WORDING of the
1729
1760
  # directive for the whole cycle; this varies STYLE per draft slot), so neither
1730
1761
  # experiment disturbs the other.
1731
1762
  STYLE_ASSIGN_FILE_B=$(mktemp -t s4l_twitter_assign_b_XXXXXX.json)
1732
- for _style_b_attempt in 1 2 3; do
1733
- s4l_pick_style twitter posting "$STYLE_ASSIGN_FILE_B" >/dev/null 2>&1 || true
1763
+ # --- Draft-B exploration source (2026-07-11) ---------------------------------
1764
+ # Style B is now the EXPLORE slot: it trials the newest human_derived styles
1765
+ # and post-2026-07-10 inventions (least-used first) instead of drawing a
1766
+ # second scored pick from the same proven pool as Style A. This is the
1767
+ # distribution channel for the standalone invent_styles.py job: the card
1768
+ # pick + the posted draft's engagement write a new style's first real score,
1769
+ # and winners graduate into the Draft-A pool via the normal sampler. NOTHING
1770
+ # is invented here (pick_exploration_style never returns mode=invent). On an
1771
+ # empty pool or API failure we fall back to the legacy second scored pick so
1772
+ # dual-draft cards never break. The source tag rides the S4L_EXP_ convention:
1773
+ # active_experiments.collect() auto-stamps it onto every plan candidate and
1774
+ # the review card's details-eye renders it with zero card-side code.
1775
+ DRAFT_B_SOURCE=$(python3 -c "
1776
+ import json, sys
1777
+ sys.path.insert(0, '$REPO_DIR/scripts')
1778
+ from engagement_styles import pick_exploration_style
1779
+ a = pick_exploration_style('twitter', context='posting', exclude={'$PICKED_STYLE'})
1780
+ if a and a.get('style'):
1781
+ with open('$STYLE_ASSIGN_FILE_B', 'w') as f:
1782
+ json.dump(a, f)
1783
+ print(a.get('source') or '')
1784
+ " 2>/dev/null || echo "")
1785
+ if [ -n "$DRAFT_B_SOURCE" ]; then
1734
1786
  PICKED_STYLE_B=$(python3 -c "
1735
1787
  import json
1736
1788
  try:
@@ -1740,7 +1792,22 @@ try:
1740
1792
  except Exception:
1741
1793
  print('')
1742
1794
  " 2>/dev/null)
1743
- PICKED_MODE_B=$(python3 -c "
1795
+ PICKED_MODE_B="use"
1796
+ fi
1797
+ if [ -z "${PICKED_STYLE_B:-}" ]; then
1798
+ DRAFT_B_SOURCE="scored_fallback"
1799
+ for _style_b_attempt in 1 2 3; do
1800
+ s4l_pick_style twitter posting "$STYLE_ASSIGN_FILE_B" >/dev/null 2>&1 || true
1801
+ PICKED_STYLE_B=$(python3 -c "
1802
+ import json
1803
+ try:
1804
+ with open('$STYLE_ASSIGN_FILE_B') as f:
1805
+ d = json.load(f)
1806
+ print(d.get('style') or '')
1807
+ except Exception:
1808
+ print('')
1809
+ " 2>/dev/null)
1810
+ PICKED_MODE_B=$(python3 -c "
1744
1811
  import json
1745
1812
  try:
1746
1813
  with open('$STYLE_ASSIGN_FILE_B') as f:
@@ -1749,44 +1816,52 @@ try:
1749
1816
  except Exception:
1750
1817
  print('use')
1751
1818
  " 2>/dev/null)
1752
- if [ "$PICKED_MODE" = "invent" ] || [ "$PICKED_MODE_B" = "invent" ] || [ "$PICKED_STYLE_B" != "$PICKED_STYLE" ]; then
1753
- break
1754
- fi
1755
- done
1756
- log "Engagement style B assigned: mode=$PICKED_MODE_B style=${PICKED_STYLE_B:-(invent)}"
1819
+ if [ "$PICKED_MODE" = "invent" ] || [ "$PICKED_MODE_B" = "invent" ] || [ "$PICKED_STYLE_B" != "$PICKED_STYLE" ]; then
1820
+ break
1821
+ fi
1822
+ done
1823
+ fi
1824
+ export S4L_EXP_DRAFT_B_SOURCE="$DRAFT_B_SOURCE"
1825
+ log "Engagement style B assigned: mode=$PICKED_MODE_B style=${PICKED_STYLE_B:-(invent)} source=$DRAFT_B_SOURCE"
1757
1826
 
1758
- # --- Draft-prompt A/B: decouple product pivot (2026-06-29) -------------------
1827
+ # --- Draft-prompt A/B: style-as-form (v3, 2026-07-10) ------------------------
1759
1828
  # Per-CYCLE arm (the prep session drafts the whole batch from ONE prompt, so
1760
1829
  # assignment is at cycle granularity, not per post; the whole batch shares it).
1761
- # control = the current draft directive verbatim.
1762
- # treatment = v2 (2026-07-06): bans the concede-then-reverse antithesis skeleton
1763
- # ('X is the easy part, the hard part is Y', "not X it's Y", etc.) in
1764
- # ANY form and forces varied entry points. v1 (2026-06-29) only
1765
- # forbade pivoting to the PRODUCT, which the model satisfied while
1766
- # keeping the skeleton (measured: treatment 30% ~= control 28% on
1767
- # 857 local replies), so v2 bans the STRUCTURE, not just the product
1768
- # tail. The SAME skeleton ban is also added to the personal_brand
1769
- # directive below (which overrides both arms), so the persona lane
1770
- # (e.g. customer personal-brand accounts like Karol) gets it too.
1771
- # Product still mentioned only when genuinely relevant.
1830
+ # control_v3 = the plain draft directive (v2's control text verbatim).
1831
+ # treatment_v3 = style-as-FORM package: the assigned engagement style is the
1832
+ # binding FORM of the draft (defining move + per-style length +
1833
+ # end-of-block self-check, rendered arm-aware by
1834
+ # engagement_styles.get_assigned_style_prompt, which reads
1835
+ # S4L_DRAFT_PROMPT_VARIANT at render time IN this cycle
1836
+ # process), PLUS the two-layer learned_preferences contract
1837
+ # (style owns form, preferences own voice inside it; the old
1838
+ # 'overrides on conflict' phrasing made the model treat the
1839
+ # style as optional), PLUS the v2 skeleton ban carried over.
1840
+ # History: v1 (2026-06-29) decoupled the product pivot; the model kept the
1841
+ # skeleton (30% ~= 28% on 857 replies). v2 (2026-07-06) banned the
1842
+ # concede-then-reverse STRUCTURE. v3 (2026-07-10) replaces v2's treatment
1843
+ # wholesale: drafts converged in shape regardless of assigned style, so v3
1844
+ # makes the style block load-bearing instead of only banning one skeleton.
1772
1845
  # The arm is stamped onto every post this cycle via S4L_DRAFT_PROMPT_VARIANT
1773
1846
  # (read by twitter_post_plan.py -> log_post.py -> posts.draft_prompt_variant),
1774
1847
  # mirroring the tail_link_variant plumbing. Split tunable via
1775
1848
  # TWITTER_DRAFT_PROMPT_AB_RATE = fraction of cycles assigned to 'treatment'.
1776
1849
  # CODE DEFAULT 0.5 = 50/50 EVERYWHERE (2026-07-06): every install runs a real
1777
- # holdback so treatment (skeleton-ban, v2) can always be measured against the old
1778
- # control prompt. The old default of 1 (100% treatment) was changed because it
1850
+ # holdback so treatment can always be measured against the plain control
1851
+ # prompt. The old default of 1 (100% treatment) was changed because it
1779
1852
  # silently dropped the control arm whenever the .env pin did not propagate to the
1780
1853
  # running env (the installed-package driver reads its OWN .env, not the source
1781
1854
  # tree's), leaving no control data. Robustly defaulting to 0.5 in code, not via an
1782
1855
  # .env override, prevents that. The dashboard reads the SAME var with the SAME
1783
1856
  # default (bin/server.js), so display and routing never diverge.
1784
1857
  DRAFT_PROMPT_AB_RATE="${TWITTER_DRAFT_PROMPT_AB_RATE:-0.5}"
1785
- # Arm VALUE versioned to '..._v2' on 2026-07-06 to RESET the experiment. The old
1786
- # 'treatment'/'control' rows (v1, decoupled-product-pivot) are retired: they stay
1787
- # in the DB under their old labels but the dashboard now counts only the '_v2'
1788
- # arms, so the v2 skeleton-ban experiment starts fresh from zero. Bump this suffix
1789
- # again on any future reset (keep bin/server.js DRAFT_PROMPT_VARIANT_DEFS in sync).
1858
+ # Arm VALUE versioned to '..._v3' on 2026-07-10 to RESET the experiment. The old
1859
+ # v1/v2 rows stay in the DB under their old labels but the dashboard now counts
1860
+ # only the '_v3' arms, so the style-as-form experiment starts fresh from zero.
1861
+ # Bump this suffix again on any future reset (keep bin/server.js
1862
+ # DRAFT_PROMPT_VARIANT_DEFS, scripts/active_experiments.py DESCRIPTIONS, and the
1863
+ # treatment_v3 gate in scripts/engagement_styles.py get_assigned_style_prompt in
1864
+ # sync).
1790
1865
  S4L_DRAFT_PROMPT_VARIANT=$(python3 -c "
1791
1866
  import random
1792
1867
  try:
@@ -1794,34 +1869,42 @@ try:
1794
1869
  except Exception:
1795
1870
  rate = 0.5
1796
1871
  rate = min(1.0, max(0.0, rate))
1797
- print('treatment_v2' if random.random() < rate else 'control_v2')
1798
- " 2>/dev/null || echo treatment_v2)
1872
+ print('treatment_v3' if random.random() < rate else 'control_v3')
1873
+ " 2>/dev/null || echo treatment_v3)
1799
1874
  export S4L_DRAFT_PROMPT_VARIANT
1800
1875
  log "Draft-prompt A/B arm: $S4L_DRAFT_PROMPT_VARIANT (rate=$DRAFT_PROMPT_AB_RATE)"
1801
- if [ "$S4L_DRAFT_PROMPT_VARIANT" = "treatment_v2" ]; then
1802
- DRAFT_DIRECTIVE="Otherwise: draft a direct, natural reply that stands on its own as a useful contribution to the thread. Mention the matched project only when it is genuinely the most relevant thing to say, and state it plainly in one clause; most replies will not need it. Do NOT use the concede-then-reverse skeleton in ANY form. Banned openings include: 'X is the easy part/half/win, the hard part is Y'; 'X was never the [thing], it's Y'; 'X isn't the [problem], it's Y'; 'the real/actual/harder part is Y'; 'what actually breaks/ships/matters is Y'; 'the part nobody says/shows is Y'; 'X is solved, Y is what breaks'. If your draft contains that concede-then-reverse pivot, rewrite it from a different entry point. This rule OVERRIDES the assigned style's example when that example uses the skeleton: keep the style's intent, not its shape. Instead lead with substance from ONE entry point, and vary the entry point across replies: a concrete first-hand specific or number; a direct answer to the exact question asked; one sharp opinion with no hedge; a genuine question that moves the thread forward; or a relevant pointer. No warm-up framing sentence before the substance. Length is governed ENTIRELY by the per-style LENGTH LIMIT in the style block above; obey that target and ceiling, do not apply any other length rule here. NEVER em dashes. Apply the matched project's \`voice\` block from ALL_PROJECTS_JSON: follow voice.tone, never violate voice.never, mirror voice.examples / voice.examples_good when present. The matched project's learned_preferences block in ALL_PROJECTS_JSON is distilled human review feedback and is MANDATORY, not advisory: follow every learned_preferences.draft_style_notes entry when writing (it overrides the engagement style's structural template on conflict), and treat learned_preferences.audience_avoid / thread_avoid matches as strong reasons to skip the candidate. Never violate content_guardrails.do_not."
1876
+ if [ "$S4L_DRAFT_PROMPT_VARIANT" = "treatment_v3" ]; then
1877
+ DRAFT_DIRECTIVE="Otherwise: draft a direct, natural reply that stands on its own as a useful contribution to the thread. Mention the matched project only when it is genuinely the most relevant thing to say, and state it plainly in one clause; most replies will not need it. THE ASSIGNED ENGAGEMENT STYLE IS THE FORM OF THIS DRAFT, not a flavor hint: the style block above defines the draft's structure, defining move, and length. Commit to that form BEFORE writing, and run the style block's self-check before returning the draft. Do NOT use the concede-then-reverse skeleton in ANY form. Banned openings include: 'X is the easy part/half/win, the hard part is Y'; 'X was never the [thing], it's Y'; 'X isn't the [problem], it's Y'; 'the real/actual/harder part is Y'; 'what actually breaks/ships/matters is Y'; 'the part nobody says/shows is Y'; 'X is solved, Y is what breaks'. If your draft contains that concede-then-reverse pivot, rewrite it from a different entry point. This ban OVERRIDES the assigned style's example when that example uses the skeleton: keep the style's defining move, express it without the skeleton. Lead with substance from ONE entry point and vary the entry point across replies: a concrete first-hand specific or number; a direct answer to the exact question asked; one sharp opinion with no hedge; a genuine question that moves the thread forward; or a relevant pointer. No warm-up framing sentence before the substance. Length is governed ENTIRELY by the per-style LENGTH LIMIT in the style block above; obey that target and ceiling, do not apply any other length rule here. NEVER em dashes. Apply the matched project's \`voice\` block from ALL_PROJECTS_JSON: follow voice.tone, never violate voice.never, mirror voice.examples / voice.examples_good when present. The matched project's learned_preferences block in ALL_PROJECTS_JSON is distilled human review feedback and is MANDATORY, not advisory, and it works TOGETHER with the engagement style on different layers: the style owns the FORM (structure, defining move, length) and learned_preferences.draft_style_notes own the voice, wording, and content choices INSIDE that form. When a preference seems to conflict with the style, keep the style's structure and satisfy the preference within it; never drop the style's defining move to satisfy a wording note. Treat learned_preferences.audience_avoid / thread_avoid matches as strong reasons to skip the candidate. Never violate content_guardrails.do_not."
1803
1878
  else
1804
1879
  DRAFT_DIRECTIVE="Otherwise: draft a reply using the best engagement style. Length is governed ENTIRELY by the per-style LENGTH LIMIT in the style block above; obey that target and ceiling, do not apply any other length rule here. NEVER em dashes. Apply the matched project's \`voice\` block from ALL_PROJECTS_JSON: follow voice.tone, never violate voice.never, mirror voice.examples / voice.examples_good when present. The matched project's learned_preferences block in ALL_PROJECTS_JSON is distilled human review feedback and is MANDATORY, not advisory: follow every learned_preferences.draft_style_notes entry when writing (it overrides the engagement style's structural template on conflict), and treat learned_preferences.audience_avoid / thread_avoid matches as strong reasons to skip the candidate. Never violate content_guardrails.do_not."
1805
1880
  fi
1806
- # Personal-brand lane (S4L_ACTIVE_LANE=personal_brand, set by saps_mode.py):
1881
+ # Personal-brand lane (S4L_ACTIVE_LANE=personal_brand, set by s4l_mode.py):
1807
1882
  # replace the product-framed directive entirely. This lane is pure organic
1808
1883
  # growth: no product, no link, no CTA. The reply must add real value grounded in
1809
1884
  # the persona's first-hand material (the PERSONA CORPUS block + the persona voice
1810
1885
  # block), not concede-and-agree filler. Replaces the product-framed promotion
1811
- # directives above, but is itself arm-aware (see below): treatment_v2 adds the
1812
- # skeleton ban, control_v2 does not, so the A/B runs in this lane too.
1886
+ # directives above, but is itself arm-aware (see below): treatment_v3 adds the
1887
+ # skeleton ban + the two-layer style/preferences contract, control_v3 keeps the
1888
+ # plain persona directive, so the A/B runs in this lane too.
1813
1889
  if [ "${S4L_ACTIVE_LANE:-}" = "personal_brand" ]; then
1814
- # Arm-aware skeleton ban in the persona lane (2026-07-06): treatment_v2 adds the
1815
- # concede-then-reverse ban clause; control_v2 keeps the plain persona directive.
1816
- # This makes the A/B measurable in the persona lane too. The arm is stamped onto
1817
- # the card + surfaced (active_experiments.py no longer drops draft_prompt for
1818
- # personal_brand), so persona cards now show treatment_v2 / control_v2.
1819
- if [ "$S4L_DRAFT_PROMPT_VARIANT" = "treatment_v2" ]; then
1820
- PERSONA_SKELETON_BAN=" Also do NOT use the concede-then-reverse skeleton in ANY form: banned openings include 'X is the easy part/half/win, the hard part is Y', 'X was never the [thing], it's Y', 'X isn't the [problem], it's Y', 'the real/actual/harder part is Y', 'what actually breaks/ships/matters is Y', 'the part nobody says/shows is Y', and 'X is solved, Y is what breaks'; if a draft has that pivot, rewrite it from one of the entry points above. This OVERRIDES the assigned style's example when that example uses the skeleton: keep the style's intent, not its shape."
1890
+ # Arm-aware persona lane (v3, 2026-07-10): treatment_v3 adds the
1891
+ # concede-then-reverse ban clause AND swaps the learned_preferences
1892
+ # relation from 'overrides the style on conflict' to the two-layer
1893
+ # contract (style owns form, preferences own voice inside it);
1894
+ # control_v3 keeps the plain persona directive. The style block itself
1895
+ # (STYLES_BLOCK, rendered in this process) is also arm-aware, so the
1896
+ # persona lane gets the style-as-FORM block on treatment automatically.
1897
+ # The arm is stamped onto the card + surfaced (active_experiments.py no
1898
+ # longer drops draft_prompt for personal_brand), so persona cards show
1899
+ # treatment_v3 / control_v3.
1900
+ if [ "$S4L_DRAFT_PROMPT_VARIANT" = "treatment_v3" ]; then
1901
+ PERSONA_SKELETON_BAN=" Also do NOT use the concede-then-reverse skeleton in ANY form: banned openings include 'X is the easy part/half/win, the hard part is Y', 'X was never the [thing], it's Y', 'X isn't the [problem], it's Y', 'the real/actual/harder part is Y', 'what actually breaks/ships/matters is Y', 'the part nobody says/shows is Y', and 'X is solved, Y is what breaks'; if a draft has that pivot, rewrite it from one of the entry points above. This ban OVERRIDES the assigned style's example when that example uses the skeleton: keep the style's defining move, express it without the skeleton."
1902
+ PERSONA_PREFS_RELATION="(learned_preferences work TOGETHER with the engagement style on different layers: the style owns the FORM, structure, defining move, and length; draft_style_notes own the voice and wording INSIDE that form; on apparent conflict keep the style's structure and satisfy the preference within it)"
1821
1903
  else
1822
1904
  PERSONA_SKELETON_BAN=""
1905
+ PERSONA_PREFS_RELATION="(it overrides the engagement style's structural template on conflict)"
1823
1906
  fi
1824
- DRAFT_DIRECTIVE="Otherwise: draft a reply that stands on its own as a genuinely useful contribution to THIS thread. Ground it in the persona's real, first-hand experience from the PERSONA CORPUS block below (specific projects, real numbers, sharp opinions, actual failures) and in the persona's \`voice\` block from ALL_PROJECTS_JSON. Add exactly ONE of: a concrete specific from that lived experience, a sharp non-obvious opinion, a useful pointer, or a question that genuinely moves the thread forward. NEVER generic agreement ('makes sense', 'this is spot on', 'great point', 'the nuance here is').${PERSONA_SKELETON_BAN} This is a personal account, not a brand: sound like a real person in the thread. If web search is available and the thread hinges on a current fact, verify it before drafting rather than guessing. Length is governed ENTIRELY by the per-style LENGTH LIMIT in the style block above; obey that target and ceiling. NEVER em dashes. Follow voice.tone, never violate voice.never, mirror voice.examples / voice.examples_good when present. The persona's learned_preferences block in ALL_PROJECTS_JSON is distilled human review feedback and is MANDATORY, not advisory: follow every learned_preferences.draft_style_notes entry when writing (it overrides the engagement style's structural template on conflict), and treat learned_preferences.audience_avoid / thread_avoid matches as strong reasons to skip the candidate. Never violate content_guardrails.do_not."
1907
+ DRAFT_DIRECTIVE="Otherwise: draft a reply that stands on its own as a genuinely useful contribution to THIS thread. Ground it in the persona's real, first-hand experience from the PERSONA CORPUS block below (specific projects, real numbers, sharp opinions, actual failures) and in the persona's \`voice\` block from ALL_PROJECTS_JSON. Add exactly ONE of: a concrete specific from that lived experience, a sharp non-obvious opinion, a useful pointer, or a question that genuinely moves the thread forward. NEVER generic agreement ('makes sense', 'this is spot on', 'great point', 'the nuance here is').${PERSONA_SKELETON_BAN} This is a personal account, not a brand: sound like a real person in the thread. If web search is available and the thread hinges on a current fact, verify it before drafting rather than guessing. Length is governed ENTIRELY by the per-style LENGTH LIMIT in the style block above; obey that target and ceiling. NEVER em dashes. Follow voice.tone, never violate voice.never, mirror voice.examples / voice.examples_good when present. The persona's learned_preferences block in ALL_PROJECTS_JSON is distilled human review feedback and is MANDATORY, not advisory: follow every learned_preferences.draft_style_notes entry when writing ${PERSONA_PREFS_RELATION}, and treat learned_preferences.audience_avoid / thread_avoid matches as strong reasons to skip the candidate. Never violate content_guardrails.do_not."
1825
1908
  fi
1826
1909
 
1827
1910
  # 2026-07-10 anti-sameness: --no-project-sections strips the multi-project
@@ -2047,7 +2130,7 @@ All project configs: $ALL_PROJECTS_JSON
2047
2130
 
2048
2131
  ## PROJECT TOP PERFORMERS (query on demand, do NOT skip routing first)
2049
2132
  The feedback reports below carry a per-style exemplar only; project winners are no longer bulk-injected. AFTER you have decided which project a candidate's draft is for, you MAY pull that project's own recent winners (last 30 days, ranked by real click rate) when you are unsure how this product converts in replies:
2050
- python3 $REPO_DIR/scripts/top_performers.py --platform twitter --project 'PROJECT_NAME' --top 3 --brief
2133
+ python3 $REPO_DIR/scripts/top_performers.py --platform twitter --project 'PROJECT_NAME' --top 3 --brief --invoked-by '$BATCH_ID'
2051
2134
  (PROJECT_NAME exactly as it appears in the candidate's 'Project match' / config.json.) Treat the results as evidence of which CLAIMS and ANGLES landed for that product, never as structural templates: do not copy their sentence shape, opener, or pivot wording. One call per project at most; skip the call entirely for projects you already queried this session.
2052
2135
 
2053
2136
  $RECENT_SELF_BLOCK
@@ -2156,6 +2239,27 @@ CRITICAL:
2156
2239
  # installs) treats --allowedTools as a one-value flag, so a space-separated second
2157
2240
  # tool would leak in as the prompt. On the box these flags ride through
2158
2241
  # claude_job.py; Desktop's own web search + the reworded prompt enable it there.
2242
+ # --- Prep-prompt snapshot (2026-07-11) ---------------------------------------
2243
+ # Persist the exact rendered PREP_PROMPT per batch so prompt-block presence is
2244
+ # verifiable after any release (grep the file), instead of reverse-engineering
2245
+ # it from package scripts. The queue's prompt-*.md files are transient work
2246
+ # files deleted on completion, and the generation trace deliberately carries
2247
+ # only the exemplar context, so this is the ONLY durable full-prompt record.
2248
+ # Local-only, newest 50 kept (file cleanup, not candidate-row retention; the
2249
+ # no-retention rule covers DB *_candidates rows). Never blocks the run.
2250
+ PREP_PROMPT_DIR="${S4L_STATE_DIR:-$HOME/.social-autoposter-mcp}/prep-prompts"
2251
+ if mkdir -p "$PREP_PROMPT_DIR" 2>/dev/null; then
2252
+ _PP_FILE="$PREP_PROMPT_DIR/prep-prompt-$BATCH_ID.md"
2253
+ if printf '%s' "$PREP_PROMPT" > "$_PP_FILE" 2>/dev/null; then
2254
+ ls -t "$PREP_PROMPT_DIR"/prep-prompt-*.md 2>/dev/null | tail -n +51 | while IFS= read -r _pp_old; do
2255
+ rm -f "$_pp_old"
2256
+ done
2257
+ log "[prep_prompt_snapshot] batch=$BATCH_ID bytes=$(wc -c < "$_PP_FILE" | tr -d ' ') path=$_PP_FILE"
2258
+ else
2259
+ log "WARN: prep-prompt snapshot write failed for batch=$BATCH_ID (non-fatal)"
2260
+ fi
2261
+ fi
2262
+
2159
2263
  PREP_OUTPUT=$(printf '%s' "$PREP_PROMPT" | "$REPO_DIR/scripts/run_claude.sh" "run-twitter-cycle-prep" --strict-mcp-config --mcp-config "$TW_MCP_CONFIG" --allowedTools WebSearch,WebFetch -p --output-format json --json-schema "$PREP_SCHEMA" 2>&1)
2160
2264
 
2161
2265
  echo "$PREP_OUTPUT" >> "$LOG_FILE"
@@ -2176,7 +2280,9 @@ POST_TOP_N="${S4L_TWITTER_POST_TOP_N:-1}"
2176
2280
  # --- ROLLING VIRALITY BAR (2026-07-02) --------------------------------------
2177
2281
  # Fetch THIS install's trailing-24h virality percentile so the parse step posts
2178
2282
  # the top-1 ONLY if it clears the bar. This holds the post rate near the target
2179
- # (~20-30 / 8h) with NO hard cap: the bar is the Nth percentile of the install's
2283
+ # (~10 / day as of 2026-07-13 reach-recovery: p0.99 replayed against the prior
2284
+ # week's picks yields ~12/day vs ~91/day at the old p0.90) with NO hard cap:
2285
+ # the bar is the Nth percentile of the install's
2180
2286
  # OWN recent candidate pool (via /api/v1/twitter-candidates/virality-threshold),
2181
2287
  # so it self-calibrates to cadence and niche instead of being a fixed number.
2182
2288
  # The bar applies to BOTH lanes (2026-07-03, per user instruction): the
@@ -2188,15 +2294,17 @@ POST_TOP_N="${S4L_TWITTER_POST_TOP_N:-1}"
2188
2294
  # - Cold start: sample_count < min, so a fresh pool posts ungated until it fills.
2189
2295
  # (This is also what keeps brand-new installs seeing every draft card.)
2190
2296
  # - Fetch failure: fail-open, never silence posting on a transient API blip.
2191
- # Virality percentile is HARDCODED to 0.90 here: single source of truth, no env
2297
+ # Virality percentile is HARDCODED to 0.99 here (raised from 0.90 on 2026-07-13,
2298
+ # reach-recovery: quality over volume): single source of truth, no env
2192
2299
  # var, no fallback, one path (every install behaves identically regardless of how
2193
2300
  # its plist was generated). Sample floor S4L_TWITTER_VIRALITY_MIN_SAMPLE default 200.
2194
- VIRALITY_THRESHOLD=$(S4L_VPCTILE="0.90" \
2301
+ VIRALITY_THRESHOLD=$(S4L_VPCTILE="0.99" \
2195
2302
  S4L_VMIN="${S4L_TWITTER_VIRALITY_MIN_SAMPLE:-200}" \
2196
2303
  S4L_SCRIPTS_DIR="$REPO_DIR/scripts" \
2197
2304
  python3 -c "
2198
2305
  import os, sys
2199
- sys.path.insert(0, os.environ.get('S4L_SCRIPTS_DIR') or os.path.expanduser('~/social-autoposter/scripts'))
2306
+ _repo = os.path.expanduser(os.environ.get('S4L_REPO_DIR') or os.environ.get('REPO_DIR') or '~/social-autoposter')
2307
+ sys.path.insert(0, os.environ.get('S4L_SCRIPTS_DIR') or os.path.join(_repo, 'scripts'))
2200
2308
  from http_api import api_get
2201
2309
  try:
2202
2310
  r = api_get('/api/v1/twitter-candidates/virality-threshold',
@@ -2211,7 +2319,7 @@ except BaseException as e:
2211
2319
  sys.stderr.write(f'virality-bar fetch failed (bar OFF this cycle): {e}\n')
2212
2320
  " 2>>"$LOG_FILE" || echo "")
2213
2321
  if [ -n "$VIRALITY_THRESHOLD" ]; then
2214
- log "Virality bar ACTIVE: p0.90 = $VIRALITY_THRESHOLD (this install, trailing 24h); top-1 kept only if it clears the bar."
2322
+ log "Virality bar ACTIVE: p0.99 = $VIRALITY_THRESHOLD (this install, trailing 24h); top-1 kept only if it clears the bar."
2215
2323
  else
2216
2324
  log "Virality bar OFF this cycle (cold-start/thin pool or fetch failed); top-1 kept ungated."
2217
2325
  fi
@@ -2364,6 +2472,33 @@ if [ "$PREP_PARSE_EXIT" -eq 0 ] && [ -f "$PLAN_FILE" ]; then
2364
2472
  fi
2365
2473
  log "Phase 2b-prep complete. plan_count=$PLAN_COUNT"
2366
2474
 
2475
+ # On-demand project-winners usage marker (2026-07-10). top_performers.py
2476
+ # appends a JSON line to the state-dir ledger on every --project call (the
2477
+ # draft prompt tells the model to pass --invoked-by "$BATCH_ID"). Count this
2478
+ # batch's lines and log a greppable marker so "did the drafting session
2479
+ # actually use the per-project query" is answerable from the cycle log alone.
2480
+ # Stderr-marker convention: format is load-bearing elsewhere; keep it stable.
2481
+ TP_ONDEMAND=$(python3 -c "
2482
+ import json, os, sys
2483
+ path = os.path.join(os.environ.get('S4L_STATE_DIR', os.path.expanduser('~/.social-autoposter-mcp')), 'top-performers-invocations.jsonl')
2484
+ n, projects = 0, []
2485
+ try:
2486
+ for line in open(path):
2487
+ try:
2488
+ r = json.loads(line)
2489
+ except Exception:
2490
+ continue
2491
+ if r.get('invoked_by') == '$BATCH_ID':
2492
+ n += 1
2493
+ p = r.get('project')
2494
+ if p and p not in projects:
2495
+ projects.append(p)
2496
+ except OSError:
2497
+ pass
2498
+ print(f'{n} projects={projects}')
2499
+ " 2>/dev/null || echo "0 projects=[]")
2500
+ log "[project_top_performers] batch=$BATCH_ID on_demand_invocations=$TP_ONDEMAND"
2501
+
2367
2502
  # twitter-browser lock was already released right after thread-media capture
2368
2503
  # (before the Claude drafting call above), since nothing from there through
2369
2504
  # Phase 2b-gen touches the browser. Phase 2b-post re-acquires unconditionally
@@ -16,7 +16,7 @@ LOG_DIR="$REPO_DIR/skill/logs"
16
16
  mkdir -p "$LOG_DIR"
17
17
  LOG_FILE="$LOG_DIR/scan-instagram-replies-$(date +%Y-%m-%d_%H%M%S).log"
18
18
 
19
- log() { echo "[$(date +%H:%M:%S)] $*" | tee -a "$LOG_FILE"; }
19
+ log() { echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] $*" | tee -a "$LOG_FILE"; }
20
20
  log "=== scan-instagram-replies fire: $(date) ==="
21
21
 
22
22
  RUN_START=$(date +%s)