@m13v/s4l 1.7.4-rc.2 → 1.7.4-rc.21

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.
@@ -345,8 +345,12 @@ def cmd_backfill(args) -> int:
345
345
  at onboarding, and it is where the author's own voice lives; product
346
346
  projects pick exemplars up on their next project_config save instead. The
347
347
  corpus write is additive (the marked section is the only thing replaced;
348
- dictation and hand-added material stay). Rate-limited by a marker file so a
349
- down browser doesn't retrigger a scan on every boot."""
348
+ dictation and hand-added material stay). No cooldown by design (user rule
349
+ 2026-07-10): every boot retries until the scan succeeds; success stamps
350
+ examples_scanned_at, which makes all later boots a cheap no-op. Boots are
351
+ user-triggered (Desktop launch), so the worst case is one scan attempt per
352
+ launch, and concurrent waiters from overlapping boots dedupe via the
353
+ post-lock eligibility re-check."""
350
354
  cfg_path = Path(args.config).expanduser() if args.config else s4l_mode.config_path()
351
355
  if not cfg_path.exists():
352
356
  print(json.dumps({"ok": True, "did": "nothing", "reason": "no_config"}))
@@ -360,20 +364,11 @@ def cmd_backfill(args) -> int:
360
364
  print(json.dumps({"ok": True, "did": "nothing", "reason": "already_done_or_hand_written"}))
361
365
  return 0
362
366
 
363
- marker = cfg_path.parent / ".voice_exemplars_backfill.json"
364
- try:
365
- last = json.loads(marker.read_text()).get("last_attempt", 0)
366
- except Exception:
367
- last = 0
368
- if time.time() - last < args.min_hours_between * 3600:
369
- print(json.dumps({"ok": True, "did": "nothing", "reason": "attempted_recently"}))
370
- return 0
371
-
372
367
  if not args.no_scan:
373
368
  lock = _wait_browser_lock(args.max_wait_minutes * 60)
374
369
  if lock is None:
375
- # Waited the whole window and never got the browser. No marker:
376
- # the wait cost nothing but time, so the next boot tries again.
370
+ # Waited the whole window and never got the browser; the next
371
+ # boot simply tries again.
377
372
  print(json.dumps({"ok": True, "did": "nothing", "reason": "browser_busy_timeout"}))
378
373
  return 0
379
374
  # The wait can be hours; another boot's backfill may have finished
@@ -387,11 +382,12 @@ def cmd_backfill(args) -> int:
387
382
  _release_browser_lock()
388
383
  print(json.dumps({"ok": True, "did": "nothing", "reason": "done_while_waiting"}))
389
384
  return 0
390
- marker.write_text(json.dumps({"last_attempt": int(time.time())}) + "\n")
391
385
  try:
392
386
  py = os.environ.get("S4L_PYTHON") or sys.executable or "python3"
387
+ # generous: the default depth (60 posts / 150 replies) can scroll
388
+ # for several minutes on prolific accounts
393
389
  r = subprocess.run([py, str(HERE / "scan_x_profile.py")],
394
- capture_output=True, text=True, timeout=300)
390
+ capture_output=True, text=True, timeout=1200)
395
391
  except Exception as e:
396
392
  print(json.dumps({"ok": False, "did": "nothing", "reason": f"scan_failed: {e}"}))
397
393
  return 0
@@ -450,8 +446,6 @@ def main(argv) -> int:
450
446
  b.add_argument("--config", default=None, help="config.json path override (testing)")
451
447
  b.add_argument("--top", type=int, default=5)
452
448
  b.add_argument("--min-chars", type=int, default=40)
453
- b.add_argument("--min-hours-between", type=float, default=24.0,
454
- help="rate limit between scan attempts")
455
449
  b.add_argument("--max-wait-minutes", type=float, default=12 * 60,
456
450
  help="how long to wait for the twitter-browser lock before "
457
451
  "giving up until the next boot")
@@ -68,6 +68,15 @@ python3 "$REPO_DIR/scripts/scan_twitter_mentions_browser.py" --json-file "$NOTIF
68
68
  || log "WARNING: Phase A scan_twitter_mentions_browser.py exited with code $?"
69
69
  rm -f "$NOTIFS_JSON"
70
70
 
71
+ # Phase A2: fill parent-thread linkage on mention-discovered rows. The
72
+ # notifications feed hides the parent tweet id, so scan rows land with
73
+ # mention_id only; this resolves post_id / parent_reply_id / project via
74
+ # fxtwitter HTTP (no browser, no model). Newest rows first, bounded per run.
75
+ log "Phase A2: Enriching parent linkage on mention replies..."
76
+ python3 "$REPO_DIR/scripts/enrich_reply_parents.py" --limit 25 2>&1 \
77
+ | tee -a "$LOG_FILE" \
78
+ || log "WARNING: Phase A2 enrich_reply_parents.py exited with code $?"
79
+
71
80
  # ═══════════════════════════════════════════════════════
72
81
  # PHASE B: Respond to pending Twitter replies
73
82
  # ═══════════════════════════════════════════════════════
@@ -0,0 +1,20 @@
1
+ #!/bin/bash
2
+ # linkedin-cadence.sh — enforce the 4-active-day / 2-day-break LinkedIn
3
+ # posting schedule (user instruction, 2026-07-11). Fired every 15 minutes by
4
+ # launchd (com.m13v.social-linkedin-cadence). All logic lives in
5
+ # scripts/linkedin_cadence.py; this wrapper just logs the tick.
6
+
7
+ set -uo pipefail
8
+ export PATH="/opt/homebrew/bin:$PATH"
9
+
10
+ REPO_DIR="$HOME/social-autoposter"
11
+ LOG_DIR="$REPO_DIR/skill/logs"
12
+ mkdir -p "$LOG_DIR"
13
+ LOG="$LOG_DIR/linkedin-cadence.log"
14
+
15
+ log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG" >&2; }
16
+
17
+ PY="/opt/homebrew/bin/python3"
18
+ [ -x "$PY" ] || PY="/usr/bin/python3"
19
+
20
+ "$PY" "$REPO_DIR/scripts/linkedin_cadence.py" enforce >>"$LOG" 2>&1
@@ -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"
@@ -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
 
@@ -977,7 +978,8 @@ if [ "$SCAN_ATTEMPT" -gt 1 ]; then
977
978
  rm -f "$UNIVERSE_EXHAUSTED_MARKER"
978
979
  PROJECTS_JSON=$(python3 - "$PROJECTS_JSON" "$TRIED_TOPICS_JSON" "$UNIVERSE_EXHAUSTED_MARKER" <<'PY' 2>>"$LOG_FILE"
979
980
  import json, os, sys
980
- sys.path.insert(0, os.path.expanduser('~/social-autoposter/scripts'))
981
+ REPO = os.path.expanduser(os.environ.get('S4L_REPO_DIR') or os.environ.get('REPO_DIR') or '~/social-autoposter')
982
+ sys.path.insert(0, os.path.join(REPO, 'scripts'))
981
983
  from pick_search_topic import pick_topic_for_project, PickerError, UniverseExhaustedError
982
984
 
983
985
  projects = json.loads(sys.argv[1] or '[]')
@@ -1349,7 +1351,8 @@ fi
1349
1351
  # search_attempts log so a Phase 1 abort still leaves the marks behind.
1350
1352
  python3 - "$PROJECTS_JSON" <<'PY' 2>&1 | tee -a "$LOG_FILE" || true
1351
1353
  import json, os, sys
1352
- sys.path.insert(0, os.path.expanduser('~/social-autoposter/scripts'))
1354
+ REPO = os.path.expanduser(os.environ.get('S4L_REPO_DIR') or os.environ.get('REPO_DIR') or '~/social-autoposter')
1355
+ sys.path.insert(0, os.path.join(REPO, 'scripts'))
1353
1356
  import project_excludes as pe
1354
1357
  projects = json.loads(sys.argv[1] or '[]')
1355
1358
  total = 0
@@ -1643,9 +1646,10 @@ done <<< "$CANDIDATES"
1643
1646
 
1644
1647
  ALL_PROJECTS_JSON=$(python3 -c "
1645
1648
  import json, os, sys
1646
- sys.path.insert(0, os.path.expanduser('~/social-autoposter/scripts'))
1649
+ repo_dir = os.path.expanduser(os.environ.get('S4L_REPO_DIR') or os.environ.get('REPO_DIR') or '~/social-autoposter')
1650
+ sys.path.insert(0, os.path.join(repo_dir, 'scripts'))
1647
1651
  import learned_preferences as lp
1648
- config = json.load(open(os.path.expanduser('~/social-autoposter/config.json')))
1652
+ config = json.load(open(os.path.join(repo_dir, 'config.json')))
1649
1653
  projects = config.get('projects', [])
1650
1654
  # learned_preferences is a SINGLE install-wide block since 2026-07-08 (see
1651
1655
  # scripts/learned_preferences.py), not one per project. Computed once here
@@ -1725,12 +1729,33 @@ log "Engagement style assigned: mode=$PICKED_MODE style=${PICKED_STYLE:-(invent)
1725
1729
  # are actually different (a same-name pair defeats the whole point); INVENT
1726
1730
  # mode on either side is accepted immediately since an invented name is
1727
1731
  # 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
1732
+ # treatment_v3/control_v3 draft-prompt A/B below (that varies WORDING of the
1729
1733
  # directive for the whole cycle; this varies STYLE per draft slot), so neither
1730
1734
  # experiment disturbs the other.
1731
1735
  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
1736
+ # --- Draft-B exploration source (2026-07-11) ---------------------------------
1737
+ # Style B is now the EXPLORE slot: it trials the newest human_derived styles
1738
+ # and post-2026-07-10 inventions (least-used first) instead of drawing a
1739
+ # second scored pick from the same proven pool as Style A. This is the
1740
+ # distribution channel for the standalone invent_styles.py job: the card
1741
+ # pick + the posted draft's engagement write a new style's first real score,
1742
+ # and winners graduate into the Draft-A pool via the normal sampler. NOTHING
1743
+ # is invented here (pick_exploration_style never returns mode=invent). On an
1744
+ # empty pool or API failure we fall back to the legacy second scored pick so
1745
+ # dual-draft cards never break. The source tag rides the S4L_EXP_ convention:
1746
+ # active_experiments.collect() auto-stamps it onto every plan candidate and
1747
+ # the review card's details-eye renders it with zero card-side code.
1748
+ DRAFT_B_SOURCE=$(python3 -c "
1749
+ import json, sys
1750
+ sys.path.insert(0, '$REPO_DIR/scripts')
1751
+ from engagement_styles import pick_exploration_style
1752
+ a = pick_exploration_style('twitter', context='posting', exclude={'$PICKED_STYLE'})
1753
+ if a and a.get('style'):
1754
+ with open('$STYLE_ASSIGN_FILE_B', 'w') as f:
1755
+ json.dump(a, f)
1756
+ print(a.get('source') or '')
1757
+ " 2>/dev/null || echo "")
1758
+ if [ -n "$DRAFT_B_SOURCE" ]; then
1734
1759
  PICKED_STYLE_B=$(python3 -c "
1735
1760
  import json
1736
1761
  try:
@@ -1740,7 +1765,22 @@ try:
1740
1765
  except Exception:
1741
1766
  print('')
1742
1767
  " 2>/dev/null)
1743
- PICKED_MODE_B=$(python3 -c "
1768
+ PICKED_MODE_B="use"
1769
+ fi
1770
+ if [ -z "${PICKED_STYLE_B:-}" ]; then
1771
+ DRAFT_B_SOURCE="scored_fallback"
1772
+ for _style_b_attempt in 1 2 3; do
1773
+ s4l_pick_style twitter posting "$STYLE_ASSIGN_FILE_B" >/dev/null 2>&1 || true
1774
+ PICKED_STYLE_B=$(python3 -c "
1775
+ import json
1776
+ try:
1777
+ with open('$STYLE_ASSIGN_FILE_B') as f:
1778
+ d = json.load(f)
1779
+ print(d.get('style') or '')
1780
+ except Exception:
1781
+ print('')
1782
+ " 2>/dev/null)
1783
+ PICKED_MODE_B=$(python3 -c "
1744
1784
  import json
1745
1785
  try:
1746
1786
  with open('$STYLE_ASSIGN_FILE_B') as f:
@@ -1749,44 +1789,52 @@ try:
1749
1789
  except Exception:
1750
1790
  print('use')
1751
1791
  " 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)}"
1792
+ if [ "$PICKED_MODE" = "invent" ] || [ "$PICKED_MODE_B" = "invent" ] || [ "$PICKED_STYLE_B" != "$PICKED_STYLE" ]; then
1793
+ break
1794
+ fi
1795
+ done
1796
+ fi
1797
+ export S4L_EXP_DRAFT_B_SOURCE="$DRAFT_B_SOURCE"
1798
+ log "Engagement style B assigned: mode=$PICKED_MODE_B style=${PICKED_STYLE_B:-(invent)} source=$DRAFT_B_SOURCE"
1757
1799
 
1758
- # --- Draft-prompt A/B: decouple product pivot (2026-06-29) -------------------
1800
+ # --- Draft-prompt A/B: style-as-form (v3, 2026-07-10) ------------------------
1759
1801
  # Per-CYCLE arm (the prep session drafts the whole batch from ONE prompt, so
1760
1802
  # 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.
1803
+ # control_v3 = the plain draft directive (v2's control text verbatim).
1804
+ # treatment_v3 = style-as-FORM package: the assigned engagement style is the
1805
+ # binding FORM of the draft (defining move + per-style length +
1806
+ # end-of-block self-check, rendered arm-aware by
1807
+ # engagement_styles.get_assigned_style_prompt, which reads
1808
+ # S4L_DRAFT_PROMPT_VARIANT at render time IN this cycle
1809
+ # process), PLUS the two-layer learned_preferences contract
1810
+ # (style owns form, preferences own voice inside it; the old
1811
+ # 'overrides on conflict' phrasing made the model treat the
1812
+ # style as optional), PLUS the v2 skeleton ban carried over.
1813
+ # History: v1 (2026-06-29) decoupled the product pivot; the model kept the
1814
+ # skeleton (30% ~= 28% on 857 replies). v2 (2026-07-06) banned the
1815
+ # concede-then-reverse STRUCTURE. v3 (2026-07-10) replaces v2's treatment
1816
+ # wholesale: drafts converged in shape regardless of assigned style, so v3
1817
+ # makes the style block load-bearing instead of only banning one skeleton.
1772
1818
  # The arm is stamped onto every post this cycle via S4L_DRAFT_PROMPT_VARIANT
1773
1819
  # (read by twitter_post_plan.py -> log_post.py -> posts.draft_prompt_variant),
1774
1820
  # mirroring the tail_link_variant plumbing. Split tunable via
1775
1821
  # TWITTER_DRAFT_PROMPT_AB_RATE = fraction of cycles assigned to 'treatment'.
1776
1822
  # 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
1823
+ # holdback so treatment can always be measured against the plain control
1824
+ # prompt. The old default of 1 (100% treatment) was changed because it
1779
1825
  # silently dropped the control arm whenever the .env pin did not propagate to the
1780
1826
  # running env (the installed-package driver reads its OWN .env, not the source
1781
1827
  # tree's), leaving no control data. Robustly defaulting to 0.5 in code, not via an
1782
1828
  # .env override, prevents that. The dashboard reads the SAME var with the SAME
1783
1829
  # default (bin/server.js), so display and routing never diverge.
1784
1830
  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).
1831
+ # Arm VALUE versioned to '..._v3' on 2026-07-10 to RESET the experiment. The old
1832
+ # v1/v2 rows stay in the DB under their old labels but the dashboard now counts
1833
+ # only the '_v3' arms, so the style-as-form experiment starts fresh from zero.
1834
+ # Bump this suffix again on any future reset (keep bin/server.js
1835
+ # DRAFT_PROMPT_VARIANT_DEFS, scripts/active_experiments.py DESCRIPTIONS, and the
1836
+ # treatment_v3 gate in scripts/engagement_styles.py get_assigned_style_prompt in
1837
+ # sync).
1790
1838
  S4L_DRAFT_PROMPT_VARIANT=$(python3 -c "
1791
1839
  import random
1792
1840
  try:
@@ -1794,12 +1842,12 @@ try:
1794
1842
  except Exception:
1795
1843
  rate = 0.5
1796
1844
  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)
1845
+ print('treatment_v3' if random.random() < rate else 'control_v3')
1846
+ " 2>/dev/null || echo treatment_v3)
1799
1847
  export S4L_DRAFT_PROMPT_VARIANT
1800
1848
  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."
1849
+ if [ "$S4L_DRAFT_PROMPT_VARIANT" = "treatment_v3" ]; then
1850
+ 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
1851
  else
1804
1852
  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
1853
  fi
@@ -1808,20 +1856,28 @@ fi
1808
1856
  # growth: no product, no link, no CTA. The reply must add real value grounded in
1809
1857
  # the persona's first-hand material (the PERSONA CORPUS block + the persona voice
1810
1858
  # 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.
1859
+ # directives above, but is itself arm-aware (see below): treatment_v3 adds the
1860
+ # skeleton ban + the two-layer style/preferences contract, control_v3 keeps the
1861
+ # plain persona directive, so the A/B runs in this lane too.
1813
1862
  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."
1863
+ # Arm-aware persona lane (v3, 2026-07-10): treatment_v3 adds the
1864
+ # concede-then-reverse ban clause AND swaps the learned_preferences
1865
+ # relation from 'overrides the style on conflict' to the two-layer
1866
+ # contract (style owns form, preferences own voice inside it);
1867
+ # control_v3 keeps the plain persona directive. The style block itself
1868
+ # (STYLES_BLOCK, rendered in this process) is also arm-aware, so the
1869
+ # persona lane gets the style-as-FORM block on treatment automatically.
1870
+ # The arm is stamped onto the card + surfaced (active_experiments.py no
1871
+ # longer drops draft_prompt for personal_brand), so persona cards show
1872
+ # treatment_v3 / control_v3.
1873
+ if [ "$S4L_DRAFT_PROMPT_VARIANT" = "treatment_v3" ]; then
1874
+ 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."
1875
+ 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
1876
  else
1822
1877
  PERSONA_SKELETON_BAN=""
1878
+ PERSONA_PREFS_RELATION="(it overrides the engagement style's structural template on conflict)"
1823
1879
  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."
1880
+ 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
1881
  fi
1826
1882
 
1827
1883
  # 2026-07-10 anti-sameness: --no-project-sections strips the multi-project
@@ -2047,7 +2103,7 @@ All project configs: $ALL_PROJECTS_JSON
2047
2103
 
2048
2104
  ## PROJECT TOP PERFORMERS (query on demand, do NOT skip routing first)
2049
2105
  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
2106
+ python3 $REPO_DIR/scripts/top_performers.py --platform twitter --project 'PROJECT_NAME' --top 3 --brief --invoked-by '$BATCH_ID'
2051
2107
  (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
2108
 
2053
2109
  $RECENT_SELF_BLOCK
@@ -2156,6 +2212,27 @@ CRITICAL:
2156
2212
  # installs) treats --allowedTools as a one-value flag, so a space-separated second
2157
2213
  # tool would leak in as the prompt. On the box these flags ride through
2158
2214
  # claude_job.py; Desktop's own web search + the reworded prompt enable it there.
2215
+ # --- Prep-prompt snapshot (2026-07-11) ---------------------------------------
2216
+ # Persist the exact rendered PREP_PROMPT per batch so prompt-block presence is
2217
+ # verifiable after any release (grep the file), instead of reverse-engineering
2218
+ # it from package scripts. The queue's prompt-*.md files are transient work
2219
+ # files deleted on completion, and the generation trace deliberately carries
2220
+ # only the exemplar context, so this is the ONLY durable full-prompt record.
2221
+ # Local-only, newest 50 kept (file cleanup, not candidate-row retention; the
2222
+ # no-retention rule covers DB *_candidates rows). Never blocks the run.
2223
+ PREP_PROMPT_DIR="${S4L_STATE_DIR:-$HOME/.social-autoposter-mcp}/prep-prompts"
2224
+ if mkdir -p "$PREP_PROMPT_DIR" 2>/dev/null; then
2225
+ _PP_FILE="$PREP_PROMPT_DIR/prep-prompt-$BATCH_ID.md"
2226
+ if printf '%s' "$PREP_PROMPT" > "$_PP_FILE" 2>/dev/null; then
2227
+ ls -t "$PREP_PROMPT_DIR"/prep-prompt-*.md 2>/dev/null | tail -n +51 | while IFS= read -r _pp_old; do
2228
+ rm -f "$_pp_old"
2229
+ done
2230
+ log "[prep_prompt_snapshot] batch=$BATCH_ID bytes=$(wc -c < "$_PP_FILE" | tr -d ' ') path=$_PP_FILE"
2231
+ else
2232
+ log "WARN: prep-prompt snapshot write failed for batch=$BATCH_ID (non-fatal)"
2233
+ fi
2234
+ fi
2235
+
2159
2236
  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
2237
 
2161
2238
  echo "$PREP_OUTPUT" >> "$LOG_FILE"
@@ -2196,7 +2273,8 @@ VIRALITY_THRESHOLD=$(S4L_VPCTILE="0.90" \
2196
2273
  S4L_SCRIPTS_DIR="$REPO_DIR/scripts" \
2197
2274
  python3 -c "
2198
2275
  import os, sys
2199
- sys.path.insert(0, os.environ.get('S4L_SCRIPTS_DIR') or os.path.expanduser('~/social-autoposter/scripts'))
2276
+ _repo = os.path.expanduser(os.environ.get('S4L_REPO_DIR') or os.environ.get('REPO_DIR') or '~/social-autoposter')
2277
+ sys.path.insert(0, os.environ.get('S4L_SCRIPTS_DIR') or os.path.join(_repo, 'scripts'))
2200
2278
  from http_api import api_get
2201
2279
  try:
2202
2280
  r = api_get('/api/v1/twitter-candidates/virality-threshold',
@@ -2364,6 +2442,33 @@ if [ "$PREP_PARSE_EXIT" -eq 0 ] && [ -f "$PLAN_FILE" ]; then
2364
2442
  fi
2365
2443
  log "Phase 2b-prep complete. plan_count=$PLAN_COUNT"
2366
2444
 
2445
+ # On-demand project-winners usage marker (2026-07-10). top_performers.py
2446
+ # appends a JSON line to the state-dir ledger on every --project call (the
2447
+ # draft prompt tells the model to pass --invoked-by "$BATCH_ID"). Count this
2448
+ # batch's lines and log a greppable marker so "did the drafting session
2449
+ # actually use the per-project query" is answerable from the cycle log alone.
2450
+ # Stderr-marker convention: format is load-bearing elsewhere; keep it stable.
2451
+ TP_ONDEMAND=$(python3 -c "
2452
+ import json, os, sys
2453
+ path = os.path.join(os.environ.get('S4L_STATE_DIR', os.path.expanduser('~/.social-autoposter-mcp')), 'top-performers-invocations.jsonl')
2454
+ n, projects = 0, []
2455
+ try:
2456
+ for line in open(path):
2457
+ try:
2458
+ r = json.loads(line)
2459
+ except Exception:
2460
+ continue
2461
+ if r.get('invoked_by') == '$BATCH_ID':
2462
+ n += 1
2463
+ p = r.get('project')
2464
+ if p and p not in projects:
2465
+ projects.append(p)
2466
+ except OSError:
2467
+ pass
2468
+ print(f'{n} projects={projects}')
2469
+ " 2>/dev/null || echo "0 projects=[]")
2470
+ log "[project_top_performers] batch=$BATCH_ID on_demand_invocations=$TP_ONDEMAND"
2471
+
2367
2472
  # twitter-browser lock was already released right after thread-media capture
2368
2473
  # (before the Claude drafting call above), since nothing from there through
2369
2474
  # Phase 2b-gen touches the browser. Phase 2b-post re-acquires unconditionally