@m13v/s4l 1.7.1-rc.2 → 1.7.1-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.
Files changed (45) hide show
  1. package/bin/cli.js +23 -0
  2. package/mcp/dist/index.js +332 -24
  3. package/mcp/dist/onboarding.js +3 -10
  4. package/mcp/dist/panel.html +42 -31
  5. package/mcp/dist/product-link.html +1 -1
  6. package/mcp/dist/runtime.js +101 -1
  7. package/mcp/dist/telemetry.js +80 -3
  8. package/mcp/dist/version.json +2 -2
  9. package/mcp/manifest.json +9 -1
  10. package/mcp/menubar/dashboard_server.py +12 -0
  11. package/mcp/menubar/s4l_card.py +472 -96
  12. package/mcp/menubar/s4l_menubar.py +269 -26
  13. package/mcp/menubar/s4l_state.py +169 -23
  14. package/mcp/package.json +1 -1
  15. package/mcp/shared/doctor.cjs +8 -0
  16. package/mcp/shared/onboarding-ledger.cjs +1 -0
  17. package/package.json +3 -1
  18. package/scripts/active_users.py +3 -6
  19. package/scripts/autopilot_stall_watch.py +200 -16
  20. package/scripts/claude_job.py +6 -0
  21. package/scripts/feedback_digest.py +34 -6
  22. package/scripts/get-latest-staging-mcpb.sh +50 -0
  23. package/scripts/harness_overlay.py +45 -0
  24. package/scripts/identity.py +26 -0
  25. package/scripts/learned_preferences.py +241 -32
  26. package/scripts/link_tail.py +167 -91
  27. package/scripts/mark_event.py +128 -0
  28. package/scripts/memory_snapshot.py +62 -0
  29. package/scripts/pick_twitter_thread_target.py +1 -1
  30. package/scripts/release-mcpb.sh +48 -0
  31. package/scripts/reset-test-machine.sh +85 -3
  32. package/scripts/s4l_mode.py +7 -5
  33. package/scripts/scheduled_tasks_snapshot.py +107 -0
  34. package/scripts/send_gmail_report.py +76 -0
  35. package/scripts/sentry_digest.py +81 -189
  36. package/scripts/sentry_init.py +43 -15
  37. package/scripts/setup_twitter_auth.py +76 -38
  38. package/scripts/snapshot.py +20 -0
  39. package/scripts/twitter_post_plan.py +39 -13
  40. package/skill/dm-outreach-twitter.sh +19 -0
  41. package/skill/engage-twitter.sh +27 -0
  42. package/skill/run-draft-and-publish.sh +13 -12
  43. package/skill/run-linkedin.sh +9 -1
  44. package/skill/run-twitter-cycle.sh +199 -27
  45. package/skill/sentry-digest.sh +125 -12
@@ -424,10 +424,11 @@ def post_one(c: dict, picker_assignment: dict | None = None) -> tuple[str, str]:
424
424
  outcome: 'posted' | 'skipped' | 'failed'
425
425
  reason: short failure key when outcome != 'posted', else ''.
426
426
 
427
- picker_assignment: optional {assigned_style, assigned_mode} dict
428
- sourced from the plan envelope. When present, drives the
429
- validate_or_register call below so USE-mode drift coerces back
430
- and INVENT-mode new_style blocks land in
427
+ picker_assignment: optional {assigned_style, assigned_mode} dict for
428
+ THIS candidate (per-candidate override when the plan carries one,
429
+ else the plan-level fallback, see call site in main()). When
430
+ present, drives the validate_or_register call below so USE-mode
431
+ drift coerces back and INVENT-mode new_style blocks land in
431
432
  engagement_styles_registry. None means legacy behaviour
432
433
  (uncoerced; whatever the model said is what gets logged).
433
434
  """
@@ -1031,13 +1032,14 @@ def main() -> int:
1031
1032
  os.environ["CLAUDE_SESSION_ID"] = plan_session_id
1032
1033
 
1033
1034
  # Pull the picker assignment from the plan envelope (written by
1034
- # run-twitter-cycle.sh after s4l_pick_style). Shared across every
1035
- # candidate in the batch because the picker fires once per cycle.
1036
- # Falls back to None on legacy plans (pre-2026-05-22 envelopes that
1037
- # don't carry these keys); post_one then runs the legacy uncoerced
1038
- # path. Empty assigned_style + assigned_mode='invent' means the
1039
- # picker rolled INVENT this cycle; validate_or_register treats that
1040
- # as "register if the model produced a well-formed new_style block".
1035
+ # run-twitter-cycle.sh after s4l_pick_style). This is the PLAN-LEVEL
1036
+ # fallback, used verbatim for legacy plans (pre-2026-05-22 envelopes, or
1037
+ # any candidate missing a per-candidate assignment) and as the last
1038
+ # resort in the per-candidate override below. Falls back to None on
1039
+ # legacy plans that don't carry these keys; post_one then runs the
1040
+ # legacy uncoerced path. Empty assigned_style + assigned_mode='invent'
1041
+ # means the picker rolled INVENT this cycle; validate_or_register treats
1042
+ # that as "register if the model produced a well-formed new_style block".
1041
1043
  picker_assignment = {
1042
1044
  "assigned_style": plan.get("assigned_style") or None,
1043
1045
  "assigned_mode": plan.get("assigned_mode") or None,
@@ -1169,7 +1171,25 @@ def main() -> int:
1169
1171
  except Exception:
1170
1172
  pass
1171
1173
  try:
1172
- outcome, reason = post_one(c, picker_assignment=picker_assignment)
1174
+ # Two-draft cards (2026-07-07): each candidate may carry its OWN
1175
+ # (assigned_style, assigned_mode) reflecting whichever draft is
1176
+ # actually posting, either the recommended one (stamped at
1177
+ # plan-write time) or the other one (stamped by the MCP
1178
+ # post_drafts edits path when a human switched drafts on the
1179
+ # review card). Without this, every card would coerce back to
1180
+ # ONE cycle-wide style even when it posted under the other
1181
+ # style, silently corrupting the engagement_style label the
1182
+ # picker's performance stats are learned from. Falls back to
1183
+ # the plan-level assignment for any candidate/plan that
1184
+ # predates this (assigned_mode key absent).
1185
+ if "assigned_mode" in c:
1186
+ _cand_assignment = {
1187
+ "assigned_style": c.get("assigned_style"),
1188
+ "assigned_mode": c.get("assigned_mode"),
1189
+ }
1190
+ else:
1191
+ _cand_assignment = picker_assignment
1192
+ outcome, reason = post_one(c, picker_assignment=_cand_assignment)
1173
1193
  except Exception as e:
1174
1194
  print(f"[post] candidate {c.get('candidate_id')} crashed: {e}",
1175
1195
  flush=True)
@@ -1247,7 +1267,13 @@ def main() -> int:
1247
1267
  "twitter post pipeline issues: "
1248
1268
  f"posted={posted} failed={failed} attempted={len(candidates)} "
1249
1269
  f"reasons={','.join(f'{k}:{v}' for k, v in machine.items())}",
1250
- level=("error" if (failed > 0 or posted == 0) else "warning"),
1270
+ # error only when a REAL failure happened (failed > 0). A batch
1271
+ # that's all benign operational skips (tweet_not_found because
1272
+ # the target vanished before we replied, rate_limited, etc.)
1273
+ # with posted==0 is not a problem, it's a batch of 1 that had
1274
+ # nothing postable. Conflating "nothing posted" with "broken"
1275
+ # used to file a fresh Sentry error for every such skip.
1276
+ level=("error" if failed > 0 else "warning"),
1251
1277
  tags={
1252
1278
  "component": "twitter_post",
1253
1279
  "posted": str(posted),
@@ -146,6 +146,25 @@ For each DM row, BEFORE you compose or send, do this in order:
146
146
  - Navigate to https://x.com/THEIR_AUTHOR (strip any leading @).
147
147
  - Snapshot the page. Extract: display name, handle, bio text, follower count, pinned/top-of-feed recent tweet topic summary.
148
148
  - If the profile is suspended, protected, or empty, capture what you can and note "profile_limited" or "profile_inaccessible".
149
+ - MANDATORY BLOCK CHECK (do this on every profile visit, not just when a
150
+ send later fails): read the page text (snapshot or DOM query) and check
151
+ for the literal string "has blocked you" (case-insensitive). This is the
152
+ ONLY sufficient evidence for concluding we're blocked -- never infer it
153
+ from a missing Message button, a screenshot, or a send failure alone
154
+ (a missing Message button just as often means "DMs closed", which is
155
+ chat_disabled below, not a block).
156
+ If that exact text IS present:
157
+ a. Skip this DM: python3 $REPO_DIR/scripts/dm_conversation.py mark-skipped \\
158
+ --dm-id DM_ID --reason "blocked_by_user"
159
+ b. Hard-block the author so future items from them are never drafted
160
+ (shared universal blocklist, same mechanism engage-twitter uses):
161
+ python3 $REPO_DIR/scripts/reply_db.py blocklist add x THEIR_AUTHOR \\
162
+ --reason "X reported this author has blocked our account (dm-outreach-twitter, profile check)" \\
163
+ --classification blocked_by_author \\
164
+ --added-by block_probe
165
+ c. Move on to the next DM row; do not attempt to send.
166
+ If that text is NOT present, proceed normally -- do not skip or block
167
+ based on any other signal from this step.
149
168
 
150
169
  3. Persist the profile fields:
151
170
  \`\`\`bash
@@ -356,6 +356,33 @@ MANDATORY reply flow for every item:
356
356
  a) Re-snapshot the page to refresh element state.
357
357
  b) Find the reply textbox: role="textbox" with name like "Post your reply"
358
358
  or "Post text". Click it.
359
+ If the textbox is NOT found, do NOT assume a technical glitch or
360
+ conclude "blocked" from a screenshot alone -- X removes the
361
+ composer but still renders the tweet normally when the author has
362
+ blocked us, so a missing textbox is genuinely ambiguous by itself.
363
+ Determine which deterministically:
364
+ i. Click the article's reply icon (data-testid="reply" on the
365
+ target article, NOT the submit button).
366
+ ii. Wait ~2s, then read the page text (snapshot or DOM query).
367
+ iii. If the text contains "has blocked you" (case-insensitive):
368
+ this is a confirmed, permanent block. Do NOT retry. Skip
369
+ this reply: python3 $REPO_DIR/scripts/reply_db.py skipped ID "author_blocked_us"
370
+ Then hard-block the author so future items from them are
371
+ never drafted (same mechanism as the escape hatch above):
372
+ python3 $REPO_DIR/scripts/reply_db.py blocklist add x HANDLE \\
373
+ --reason "X reported this author has blocked our account (live reply attempt, engage-twitter)" \\
374
+ --classification blocked_by_author \\
375
+ --added-by block_probe \\
376
+ --source-reply-id REPLY_ID
377
+ Move on to the next assigned reply.
378
+ iv. If that text is NOT present: this was a transient render
379
+ miss, not a block. The click in (i) may have opened the
380
+ composer -- re-snapshot; if the textbox is now present,
381
+ continue with it below. If still absent, retry this whole
382
+ step once more before treating it as a failed technical
383
+ attempt.
384
+ NEVER conclude "blocked" from a screenshot alone -- only the
385
+ literal "has blocked you" text match above is sufficient evidence.
359
386
  c) Type YOUR_REPLY_TEXT (post-Step-3b short-link-wrapped form, post-Step-3a
360
387
  suffix) into that textbox. Do NOT auto-submit; we click the Reply
361
388
  button explicitly in step e.
@@ -123,20 +123,21 @@ if [ -n "${S4L_FORCE_PROJECT:-}" ]; then
123
123
  echo "[run-draft-and-publish] personal_brand mode: forcing project '$S4L_FORCE_PROJECT' (link-free)" >&2
124
124
  fi
125
125
 
126
- # Draft-only flag (2026-07-06). Per-cycle DRAFT_ONLY decision, overriding the
127
- # plist's baked DRAFT_ONLY=1 baseline:
128
- # - draft-only ON (default), or persona cycle -> DRAFT_ONLY=1: draft stops
129
- # before posting and the plan merges into review cards, exactly as before.
130
- # - draft-only OFF + promotion cycle -> DRAFT_ONLY=0: the cycle POSTS its
131
- # top-1 pick autonomously; the rolling virality bar activates automatically
132
- # as the no-human quality gate.
133
- # S4L_CYCLE_LANE comes from the s4l_mode env eval above; when absent (e.g. a
134
- # personal_brand-only setup with no persona provisioned yet) we default to the
135
- # safe card path.
126
+ # Draft-only flag (2026-07-06, made lane-uniform 2026-07-08). Per-cycle
127
+ # DRAFT_ONLY decision, overriding the plist's baked DRAFT_ONLY=1 baseline:
128
+ # - draft-only ON (default): DRAFT_ONLY=1 for every lane; draft stops before
129
+ # posting and the plan merges into review cards, exactly as before.
130
+ # - draft-only OFF: DRAFT_ONLY=0 for every lane (promotion AND
131
+ # personal_brand); the cycle POSTS its top-1 pick autonomously. The
132
+ # rolling virality bar activates automatically as the no-human quality
133
+ # gate for promotion cycles.
134
+ # Previously this only applied to the promotion lane (personal_brand always
135
+ # forced DRAFT_ONLY=1). Operator instruction 2026-07-08: draft-only must be a
136
+ # single global switch that covers every lane, not just promotion.
136
137
  DRAFT_ONLY_FLAG="$("$PY" "$REPO_DIR/scripts/s4l_mode.py" draft-only 2>/dev/null || echo 1)"
137
- if [ "${S4L_CYCLE_LANE:-}" = "promotion" ] && [ "$DRAFT_ONLY_FLAG" = "0" ]; then
138
+ if [ "$DRAFT_ONLY_FLAG" = "0" ]; then
138
139
  export DRAFT_ONLY=0
139
- echo "[run-draft-and-publish] draft-only OFF: promotion cycle will POST autonomously (DRAFT_ONLY=0, virality bar active)" >&2
140
+ echo "[run-draft-and-publish] draft-only OFF: cycle (lane=${S4L_CYCLE_LANE:-unknown}) will POST autonomously (DRAFT_ONLY=0)" >&2
140
141
  else
141
142
  export DRAFT_ONLY=1
142
143
  fi
@@ -1094,9 +1094,17 @@ echo "Phase A: chose project=$PA_PROJECT topic='$PA_SEARCH_TOPIC' activity=$PA_A
1094
1094
 
1095
1095
  # Look up the chosen project's full config (only this one).
1096
1096
  PROJECT_FULL=$(python3 -c "
1097
- import json, os
1097
+ import json, os, sys
1098
+ sys.path.insert(0, os.path.expanduser('~/social-autoposter/scripts'))
1099
+ import learned_preferences as lp
1098
1100
  c = json.load(open(os.path.expanduser('~/social-autoposter/config.json')))
1099
1101
  p = next((p for p in c.get('projects',[]) if p['name']=='$PA_PROJECT'), {})
1102
+ # learned_preferences is a SINGLE install-wide block since 2026-07-08 (see
1103
+ # scripts/learned_preferences.py), not one per project. Stamped in under the
1104
+ # same key so the existing prose below ('learned_preferences.draft_style_notes
1105
+ # entry ... overrides the engagement style') keeps working unchanged.
1106
+ p = dict(p)
1107
+ p['learned_preferences'] = lp.get_global_block(c)
1100
1108
  print(json.dumps(p, indent=2))
1101
1109
  ")
1102
1110
 
@@ -1748,9 +1748,17 @@ Project match: $cproject${DRAFT_LINE}${AUTHOR_HISTORY_LINE}
1748
1748
  done <<< "$CANDIDATES"
1749
1749
 
1750
1750
  ALL_PROJECTS_JSON=$(python3 -c "
1751
- import json, os
1751
+ import json, os, sys
1752
+ sys.path.insert(0, os.path.expanduser('~/social-autoposter/scripts'))
1753
+ import learned_preferences as lp
1752
1754
  config = json.load(open(os.path.expanduser('~/social-autoposter/config.json')))
1753
1755
  projects = config.get('projects', [])
1756
+ # learned_preferences is a SINGLE install-wide block since 2026-07-08 (see
1757
+ # scripts/learned_preferences.py), not one per project. Computed once here
1758
+ # and stamped onto every emitted project dict under the same key so the
1759
+ # existing DRAFT_DIRECTIVE prose ('The matched project's learned_preferences
1760
+ # block in ALL_PROJECTS_JSON is MANDATORY...') keeps working unchanged.
1761
+ global_lp = lp.get_global_block(config)
1754
1762
  lane = os.environ.get('S4L_ACTIVE_LANE', '')
1755
1763
  if lane == 'personal_brand':
1756
1764
  # Personal-brand lane is pure organic growth: the drafter must NOT see any
@@ -1773,10 +1781,17 @@ if lane == 'personal_brand':
1773
1781
  persona = next((p for p in projects if p.get('persona') is True), None)
1774
1782
  out = {}
1775
1783
  if persona:
1776
- out[persona['name']] = {k: v for k, v in persona.items() if k in ALLOWED}
1784
+ entry = {k: v for k, v in persona.items() if k in ALLOWED}
1785
+ entry['learned_preferences'] = global_lp
1786
+ out[persona['name']] = entry
1777
1787
  print(json.dumps(out, indent=2))
1778
1788
  else:
1779
- print(json.dumps({p['name']: p for p in projects}, indent=2))
1789
+ out = {}
1790
+ for p in projects:
1791
+ entry = dict(p)
1792
+ entry['learned_preferences'] = global_lp
1793
+ out[p['name']] = entry
1794
+ print(json.dumps(out, indent=2))
1780
1795
  " 2>/dev/null || echo "{}")
1781
1796
 
1782
1797
  # Engagement-style picker (2026-05-19): pick ONE assigned style for this
@@ -1808,6 +1823,44 @@ except Exception:
1808
1823
  " 2>/dev/null)
1809
1824
  log "Engagement style assigned: mode=$PICKED_MODE style=${PICKED_STYLE:-(invent)}"
1810
1825
 
1826
+ # --- Second engagement style for dual-draft review cards (2026-07-07) -------
1827
+ # Two-draft cards: pick a SECOND style ("Style B") this cycle, independent of
1828
+ # the first ("Style A" = PICKED_STYLE/PICKED_MODE above). The prep step drafts
1829
+ # every fresh candidate once under EACH style and the reviewer picks which
1830
+ # posts; see PREP_SCHEMA/PREP_PROMPT below. Bounded retry so the two styles
1831
+ # are actually different (a same-name pair defeats the whole point); INVENT
1832
+ # mode on either side is accepted immediately since an invented name is
1833
+ # definitionally distinct from a pinned one. This is orthogonal to the
1834
+ # treatment_v2/control_v2 draft-prompt A/B below (that varies WORDING of the
1835
+ # directive for the whole cycle; this varies STYLE per draft slot), so neither
1836
+ # experiment disturbs the other.
1837
+ STYLE_ASSIGN_FILE_B=$(mktemp -t s4l_twitter_assign_b_XXXXXX.json)
1838
+ for _style_b_attempt in 1 2 3; do
1839
+ s4l_pick_style twitter posting "$STYLE_ASSIGN_FILE_B" >/dev/null 2>&1 || true
1840
+ PICKED_STYLE_B=$(python3 -c "
1841
+ import json
1842
+ try:
1843
+ with open('$STYLE_ASSIGN_FILE_B') as f:
1844
+ d = json.load(f)
1845
+ print(d.get('style') or '')
1846
+ except Exception:
1847
+ print('')
1848
+ " 2>/dev/null)
1849
+ PICKED_MODE_B=$(python3 -c "
1850
+ import json
1851
+ try:
1852
+ with open('$STYLE_ASSIGN_FILE_B') as f:
1853
+ d = json.load(f)
1854
+ print(d.get('mode') or 'use')
1855
+ except Exception:
1856
+ print('use')
1857
+ " 2>/dev/null)
1858
+ if [ "$PICKED_MODE" = "invent" ] || [ "$PICKED_MODE_B" = "invent" ] || [ "$PICKED_STYLE_B" != "$PICKED_STYLE" ]; then
1859
+ break
1860
+ fi
1861
+ done
1862
+ log "Engagement style B assigned: mode=$PICKED_MODE_B style=${PICKED_STYLE_B:-(invent)}"
1863
+
1811
1864
  # --- Draft-prompt A/B: decouple product pivot (2026-06-29) -------------------
1812
1865
  # Per-CYCLE arm (the prep session drafts the whole batch from ONE prompt, so
1813
1866
  # assignment is at cycle granularity, not per post; the whole batch shares it).
@@ -1882,6 +1935,11 @@ if [ -n "$PICKED_STYLE" ]; then
1882
1935
  else
1883
1936
  TOP_REPORT=$(python3 "$REPO_DIR/scripts/top_performers.py" --platform twitter 2>/dev/null || echo "(top performers report unavailable)")
1884
1937
  fi
1938
+ if [ -n "$PICKED_STYLE_B" ]; then
1939
+ TOP_REPORT_B=$(python3 "$REPO_DIR/scripts/top_performers.py" --platform twitter --style "$PICKED_STYLE_B" 2>/dev/null || echo "(top performers report unavailable)")
1940
+ else
1941
+ TOP_REPORT_B=$(python3 "$REPO_DIR/scripts/top_performers.py" --platform twitter 2>/dev/null || echo "(top performers report unavailable)")
1942
+ fi
1885
1943
 
1886
1944
  # --- Generation trace -------------------------------------------------------
1887
1945
  # Snapshot the few-shot context this cycle will feed to Claude — top_performers
@@ -1924,9 +1982,26 @@ else
1924
1982
  fi
1925
1983
 
1926
1984
  STYLES_BLOCK=$(s4l_render_style_block "$STYLE_ASSIGN_FILE" twitter posting)
1927
- # Style assignment file is the same one we picked above; styles.sh already sourced.
1985
+ # Style B block: the assigned-style portion only (description/example/note/
1986
+ # length/grounding rule), NOT the full s4l_render_style_block — content rules,
1987
+ # anti-patterns, and the voice-relationship rule are platform/project-wide, not
1988
+ # per-style, and are already embedded once via STYLES_BLOCK above. Calling
1989
+ # get_assigned_style_prompt() directly here avoids repeating those shared
1990
+ # sections a second time and keeps skill/styles.sh untouched.
1991
+ STYLES_BLOCK_B=$(python3 -c "
1992
+ import json, sys
1993
+ sys.path.insert(0, '$REPO_DIR/scripts')
1994
+ from engagement_styles import get_assigned_style_prompt
1995
+ try:
1996
+ with open('$STYLE_ASSIGN_FILE_B', 'r') as f:
1997
+ assignment = json.load(f)
1998
+ print(get_assigned_style_prompt('twitter', assignment, context='posting'))
1999
+ except Exception:
2000
+ print('(style module unavailable)')
2001
+ " 2>/dev/null || echo "(style module unavailable)")
2002
+ # Style assignment files are the ones we picked above; styles.sh already sourced.
1928
2003
  # Cleanup at cycle end (best effort).
1929
- trap 'rm -f "$STYLE_ASSIGN_FILE" 2>/dev/null || true' EXIT
2004
+ trap 'rm -f "$STYLE_ASSIGN_FILE" "$STYLE_ASSIGN_FILE_B" 2>/dev/null || true' EXIT
1930
2005
 
1931
2006
  # Phase 2b is split into three sub-phases so the twitter-browser lock is only
1932
2007
  # held during actual browser work. The killer in the old single-session flow
@@ -2009,22 +2084,26 @@ log "Phase 2b-prep: Claude reading threads and drafting replies (no post cap)...
2009
2084
  CLAUDE_SESSION_ID="$(uuidgen | tr 'A-Z' 'a-z')"
2010
2085
  export CLAUDE_SESSION_ID
2011
2086
 
2012
- # PREP_SCHEMA — strict JSON schema for the prep envelope. Includes
2013
- # optional `new_style` per candidate (an inner object) that the model
2014
- # MUST populate when it chooses a brand-new engagement style name (i.e.
2015
- # the picker set mode=invent and the model invented a snake_case name).
2016
- # Fields mirror engagement_styles.py::_REQUIRED_NEW_STYLE_FIELDS so the
2017
- # downstream validate_or_register call accepts the block without a
2018
- # second schema layer.
2019
- PREP_SCHEMA='{"type":"object","properties":{"candidates":{"type":"array","items":{"type":"object","properties":{"candidate_id":{"type":"integer"},"candidate_url":{"type":"string"},"thread_author":{"type":"string"},"thread_text":{"type":"string"},"matched_project":{"type":"string"},"reply_text":{"type":"string"},"engagement_style":{"type":"string"},"new_style":{"type":["object","null"],"properties":{"description":{"type":"string"},"example":{"type":"string"},"why_existing_didnt_fit":{"type":"string"},"note":{"type":"string"},"target_chars":{"type":"integer"}}},"language":{"type":"string"},"reply_text_en":{"type":"string"},"thread_text_en":{"type":"string"},"has_landing_pages":{"type":"boolean"},"link_keyword":{"type":"string"},"link_slug":{"type":"string"},"search_topic":{"type":"string"}},"required":["candidate_id","candidate_url","matched_project","reply_text","engagement_style","language","has_landing_pages","search_topic"]}},"rejected":{"type":"array","items":{"type":"object","properties":{"candidate_id":{"type":"integer"},"reason":{"type":"string"},"proposed_excludes":{"type":"array","items":{"type":"string"}}},"required":["candidate_id","reason"]}}},"required":["candidates","rejected"]}'
2087
+ # PREP_SCHEMA — strict JSON schema for the prep envelope. Two-draft cards
2088
+ # (2026-07-07, no-recommendation redesign 2026-07-08): each candidate
2089
+ # carries draft_a_* / draft_b_* (one per assigned style) instead of a
2090
+ # single reply_text/engagement_style, plus is_reused_draft. The model does
2091
+ # NOT pick a favorite; Draft A is always the default shown/selected on the
2092
+ # review card, the reviewer's own click chooses otherwise. Includes optional
2093
+ # `draft_a_new_style` / `draft_b_new_style` per candidate (inner objects) that
2094
+ # the model MUST populate when that slot's picker set mode=invent and it
2095
+ # invented a snake_case name. Fields mirror
2096
+ # engagement_styles.py::_REQUIRED_NEW_STYLE_FIELDS so the downstream
2097
+ # validate_or_register call accepts the block without a second schema layer.
2098
+ PREP_SCHEMA='{"type":"object","properties":{"candidates":{"type":"array","items":{"type":"object","properties":{"candidate_id":{"type":"integer"},"candidate_url":{"type":"string"},"thread_author":{"type":"string"},"thread_text":{"type":"string"},"matched_project":{"type":"string"},"is_reused_draft":{"type":"boolean"},"draft_a_text":{"type":"string"},"draft_a_style":{"type":"string"},"draft_a_new_style":{"type":["object","null"],"properties":{"description":{"type":"string"},"example":{"type":"string"},"why_existing_didnt_fit":{"type":"string"},"note":{"type":"string"},"target_chars":{"type":"integer"}}},"draft_a_text_en":{"type":["string","null"]},"draft_b_text":{"type":["string","null"]},"draft_b_style":{"type":["string","null"]},"draft_b_new_style":{"type":["object","null"],"properties":{"description":{"type":"string"},"example":{"type":"string"},"why_existing_didnt_fit":{"type":"string"},"note":{"type":"string"},"target_chars":{"type":"integer"}}},"draft_b_text_en":{"type":["string","null"]},"language":{"type":"string"},"thread_text_en":{"type":"string"},"has_landing_pages":{"type":"boolean"},"link_keyword":{"type":"string"},"link_slug":{"type":"string"},"search_topic":{"type":"string"}},"required":["candidate_id","candidate_url","matched_project","is_reused_draft","draft_a_text","draft_a_style","draft_b_text","draft_b_style","language","has_landing_pages","search_topic"]}},"rejected":{"type":"array","items":{"type":"object","properties":{"candidate_id":{"type":"integer"},"reason":{"type":"string"},"proposed_excludes":{"type":"array","items":{"type":"string"}}},"required":["candidate_id","reason"]}}},"required":["candidates","rejected"]}'
2020
2099
 
2021
2100
  PREP_PROMPT="${TW_ENGINE_PREFIX}You are the Social Autoposter prep step.
2022
2101
 
2023
2102
  Your ONLY job in THIS session:
2024
2103
  1. Read each candidate's thread context from the PRE-SCORED CANDIDATES block below (each entry's 'Text:' field is the parent tweet). You have WebSearch and WebFetch available: use them ONLY when a thread hinges on a current fact, a name, a release, or a claim you are not sure about, so your reply is specific and correct instead of vague. You do NOT have the Twitter/X browser this session — never fetch, navigate, or open a tweet/x.com URL, and never try to load the thread itself; the thread text you need is already inlined below. Most replies need no search at all; reach for it only when it materially improves the reply.
2025
- 2. Draft a reply for each.
2026
- 3. Persist each fresh draft via log_draft.py.
2027
- 4. Emit a structured plan describing the chosen candidates, the reply text, and (when applicable) the SEO link keyword + slug.
2104
+ 2. Draft TWO independent replies for each fresh candidate, one under Draft A's assigned style and one under Draft B's assigned style below. Do not judge or rank them, the reviewer reads both and picks.
2105
+ 3. Persist the recommended fresh draft via log_draft.py.
2106
+ 4. Emit a structured plan describing the chosen candidates, both draft texts, and (when applicable) the SEO link keyword + slug.
2028
2107
 
2029
2108
  You will NOT post anything. You will NOT generate landing pages. You will NOT call log_post.py. The shell handles all of that AFTER your session ends, with the browser lock released for the long landing-page build.
2030
2109
 
@@ -2041,11 +2120,16 @@ $CORPUS_BLOCK
2041
2120
  Each candidate has a 'Project match' field. Use that project unless the thread content clearly better fits another project.
2042
2121
  All project configs: $ALL_PROJECTS_JSON
2043
2122
 
2044
- ## FEEDBACK FROM PAST PERFORMANCE:
2123
+ ## DRAFT A: assigned style + feedback from past performance
2045
2124
  $TOP_REPORT
2046
2125
 
2047
2126
  $STYLES_BLOCK
2048
2127
 
2128
+ ## DRAFT B: assigned style + feedback from past performance
2129
+ $TOP_REPORT_B
2130
+
2131
+ $STYLES_BLOCK_B
2132
+
2049
2133
  ## WORKFLOW
2050
2134
  There is NO cap on how many candidates you may pick this cycle. Pick EVERY candidate whose thread is genuinely on-brand and worth a substantive reply. Skip a candidate ONLY when its thread is off-topic for the matched project, toxic / hateful, low-quality / spam, an audience mismatch, or a near-duplicate of something already replied to. Do NOT cap, quota, or balance picks by project: if the strongest candidates this cycle all belong to one project, pick all of them. Project routing matters; project diversification does not. Never force a weak entry just to add volume, and never drop a strong on-brand entry just to limit volume.
2051
2135
 
@@ -2053,14 +2137,18 @@ For each chosen candidate:
2053
2137
  1. Read the candidate's parent tweet from its 'Text:' field in the PRE-SCORED CANDIDATES block above.
2054
2138
  2. Understand the context from that inlined text (the thread text is already in this prompt; you do NOT have the Twitter browser, but you MAY use WebSearch/WebFetch for external facts when a thread needs them to be answered well).
2055
2139
  3. DRAFT HANDLING (existing vs fresh):
2056
- - If the candidate block shows an EXISTING DRAFT line AND draft age < 30 minutes, REUSE the draft text verbatim. Set engagement_style to the existing style. Do NOT call log_draft.py; do NOT redraft. Reason: prior cycle paid the LLM cost.
2057
- - $DRAFT_DIRECTIVE
2058
- 3a. PERSIST FRESH DRAFTS (skip for reused drafts):
2059
- python3 $REPO_DIR/scripts/log_draft.py --candidate-id CANDIDATE_ID --text 'YOUR_REPLY_TEXT' --style STYLE --assigned-style '$PICKED_STYLE' --assigned-mode '$PICKED_MODE'
2140
+ - If the candidate block shows an EXISTING DRAFT line AND draft age < 30 minutes, REUSE the draft text verbatim as draft_a_text/draft_a_style (set is_reused_draft=true, draft_b_text=null, draft_b_style=null). Do NOT call log_draft.py; do NOT redraft; do NOT write a second variant, prior cycle already paid the LLM cost for the one draft you have.
2141
+ - Otherwise (fresh candidate, is_reused_draft=false): write TWO independent drafts. Do NOT judge, rank, or pick a favorite between them, both are shown to the reviewer, who decides.
2142
+ - draft_a_text: follow the DRAFT A style block above (its own description/example/note/length limit).
2143
+ - draft_b_text: follow the DRAFT B style block above, written INDEPENDENTLY from scratch as if draft_a_text did not exist. Do NOT lightly reword draft_a_text into draft_b_text, they must diverge in length and rhetorical move because they follow different style templates, not just differ in phrasing. If you notice draft_b_text ending up as a paraphrase of draft_a_text, stop and rewrite it from Style B's own example instead.
2144
+ - $DRAFT_DIRECTIVE (applies to both drafts on fresh candidates; each still obeys its OWN style's length limit, not a shared one).
2145
+ 3a. PERSIST DRAFT A (skip entirely for reused drafts):
2146
+ python3 $REPO_DIR/scripts/log_draft.py --candidate-id CANDIDATE_ID --text 'DRAFT_A_TEXT' --style DRAFT_A_STYLE --assigned-style '$PICKED_STYLE' --assigned-mode '$PICKED_MODE'
2147
+ Always persist draft_a_text/draft_a_style here (Draft A is the single-draft representative used if a near-immediate next cycle reuses this candidate's draft verbatim per step 3 above); never draft_b.
2060
2148
  The --assigned-style / --assigned-mode flags carry the orchestrator's picker output (this cycle: mode=$PICKED_MODE style='${PICKED_STYLE:-(invent)}') into the candidate row so the post pipeline can coerce drift and register invented styles. Pass them VERBATIM as shown.
2061
- If you are inventing a brand-new style this cycle (i.e. \$PICKED_MODE=invent and your STYLE is a new snake_case name not in the style block above), ALSO pass:
2149
+ If Draft A used an invented style (i.e. mode is invent and its STYLE is a new snake_case name not in the Draft A style block), ALSO pass:
2062
2150
  --new-style '{\"description\":\"...\",\"example\":\"...\",\"why_existing_didnt_fit\":\"...\"}'
2063
- with the same description/example/why_existing_didnt_fit you put in the 'new_style' field of your output JSON for this candidate.
2151
+ with the same description/example/why_existing_didnt_fit you put in draft_a_new_style in your output JSON for this candidate.
2064
2152
  Failure here is non-fatal, log a warning and continue.
2065
2153
  4. EMIT one entry in the structured 'candidates' array with these fields:
2066
2154
  - candidate_id (int): from the candidate block
@@ -2068,11 +2156,16 @@ For each chosen candidate:
2068
2156
  - thread_author (string): the @handle (no leading @)
2069
2157
  - thread_text (string): the parent tweet's text, condensed to <=500 chars if needed
2070
2158
  - matched_project (string): the project name to attribute this post to
2071
- - reply_text (string): the FINAL reply text WITHOUT any URL appended (the shell appends the URL later). 250 chars is the hard ceiling (leaves room for a 23-char t.co link inside the 280-char cap) — stay well under it, not up to it.
2072
- - engagement_style (string): style name applied (or 'reused' for an unchanged stale draft). In USE mode ($PICKED_MODE=use) this MUST be the assigned style name '${PICKED_STYLE}' verbatim; the orchestrator silently coerces drift back. In INVENT mode ($PICKED_MODE=invent) this MUST be a NEW snake_case style name not in the curated style block.
2073
- - new_style (object, REQUIRED iff INVENT mode produced a new name; OMIT or set null otherwise): {description (string), example (string), why_existing_didnt_fit (string), note (string, optional), target_chars (integer, REQUIRED)}. Same shape you passed to --new-style in step 3a. The post pipeline reads this and POSTs to /api/v1/engagement-styles/registry so the new style lands in engagement_styles_registry alongside Reddit/GitHub/Moltbook inventions. target_chars is the comment length THIS new style wins at, in characters. IMPORTANT: the example you write must be EXACTLY that length — the example IS the canonical length reference, and the hard ceiling is target_chars × 1.5. Write the example first, count its characters, then set target_chars to that count. Bias SHORT: one-liner style ~45, story-arc style up to ~180, never above 220.
2159
+ - is_reused_draft (bool, REQUIRED): true iff you reused an existing draft verbatim per step 3 above, false for a freshly-drafted candidate.
2160
+ - draft_a_text (string, REQUIRED): the FINAL Draft A reply text WITHOUT any URL appended (the shell appends the URL later). 250 chars is the hard ceiling (leaves room for a 23-char t.co link inside the 280-char cap) — stay well under it, not up to it. On a reused candidate this IS the reused text.
2161
+ - draft_a_style (string, REQUIRED): style name applied to draft_a_text (or the reused candidate's existing style). In USE mode ($PICKED_MODE=use) this MUST be the Draft A assigned style name '${PICKED_STYLE}' verbatim; the orchestrator silently coerces drift back. In INVENT mode ($PICKED_MODE=invent) this MUST be a NEW snake_case style name not in the Draft A style block.
2162
+ - draft_a_new_style (object, REQUIRED iff Draft A's INVENT mode produced a new name; OMIT or set null otherwise): {description (string), example (string), why_existing_didnt_fit (string), note (string, optional), target_chars (integer, REQUIRED)}. target_chars is the comment length THIS new style wins at, in characters; the example you write must be EXACTLY that length, write the example first, count its characters, then set target_chars to that count. Bias SHORT: one-liner style ~45, story-arc style up to ~180, never above 220.
2163
+ - draft_a_text_en (string, REQUIRED when language != 'en'; null when language == 'en'): a faithful English translation of draft_a_text. Display-only: the review card shows it so the operator can understand a non-English draft; it is NEVER posted. Translate meaning, not word-by-word; no added commentary.
2164
+ - draft_b_text (string, REQUIRED when is_reused_draft=false; null when is_reused_draft=true): the FINAL Draft B reply text, same rules as draft_a_text, written under the DRAFT B style block instead.
2165
+ - draft_b_style (string, REQUIRED when is_reused_draft=false; null when is_reused_draft=true): style name applied to draft_b_text, same rules as draft_a_style but against the Draft B assignment (USE mode style '${PICKED_STYLE_B}', mode $PICKED_MODE_B).
2166
+ - draft_b_new_style (object, REQUIRED iff Draft B's INVENT mode produced a new name; OMIT or set null otherwise): same shape as draft_a_new_style.
2167
+ - draft_b_text_en (string, REQUIRED when is_reused_draft=false AND language != 'en'; null otherwise): faithful English translation of draft_b_text, same display-only rules as draft_a_text_en.
2074
2168
  - language (string): ISO 639-1 code (en, ja, zh, es, ...)
2075
- - reply_text_en (string, REQUIRED when language != 'en'; OMIT when language == 'en'): a faithful English translation of reply_text. Display-only: the review card shows it so the operator can understand a non-English draft; it is NEVER posted. Translate meaning, not word-by-word; no added commentary.
2076
2169
  - thread_text_en (string, REQUIRED when language != 'en'; OMIT when language == 'en'): a faithful English translation of thread_text (same <=500 char condensation). Display-only, never posted.
2077
2170
  - has_landing_pages (bool): true iff the matched project has BOTH landing_pages.repo AND landing_pages.base_url set in config.json. Otherwise false.
2078
2171
  - link_keyword (string, REQUIRED when has_landing_pages=true; OMIT otherwise): a SHORT 3-6 word phrase that captures the ESSENCE OF YOUR REPLY (not just the thread topic). Think: what would a reader search to find a useful page about what you just said?
@@ -2267,6 +2360,47 @@ except Exception as _e:
2267
2360
  print(f'prep: experiments stamp failed: {_e}', file=sys.stderr)
2268
2361
  for _c in candidates:
2269
2362
  _c['experiments'] = dict(_exps)
2363
+ # Two-draft cards (2026-07-07; no-recommendation redesign 2026-07-08):
2364
+ # mirror Draft A onto the canonical single-draft fields (reply_text/
2365
+ # engagement_style/new_style/reply_text_en/assigned_style/assigned_mode)
2366
+ # so every existing downstream consumer (tail-link baking, dedup,
2367
+ # translations, posting, drift coercion) keeps working unchanged by
2368
+ # default. Draft A is always the default, the model is never asked to
2369
+ # pick a favorite, only the reviewer's own click on the card overrides
2370
+ # it. Additionally attach a 'drafts' array for the review card to offer
2371
+ # a switch; reused/stale candidates (single draft, no draft_b) don't get
2372
+ # a drafts array, so the card falls back to today's single-draft UI.
2373
+ _rec = 'a'
2374
+ _is_reused = bool(_c.get('is_reused_draft'))
2375
+ _rec_text = _c.get(f'draft_{_rec}_text') or ''
2376
+ _rec_style = _c.get(f'draft_{_rec}_style') or ''
2377
+ _rec_new_style = _c.get(f'draft_{_rec}_new_style')
2378
+ _rec_text_en = _c.get(f'draft_{_rec}_text_en')
2379
+ _c['reply_text'] = _rec_text
2380
+ _c['engagement_style'] = _rec_style
2381
+ _c['new_style'] = _rec_new_style
2382
+ if _rec_text_en:
2383
+ _c['reply_text_en'] = _rec_text_en
2384
+ _c['assigned_style'] = ('$PICKED_STYLE' or None) if _rec == 'a' else ('$PICKED_STYLE_B' or None)
2385
+ _c['assigned_mode'] = ('$PICKED_MODE' or 'use') if _rec == 'a' else ('$PICKED_MODE_B' or 'use')
2386
+ _draft_b_text = _c.get('draft_b_text')
2387
+ if not _is_reused and _draft_b_text:
2388
+ _c['drafts'] = [
2389
+ {
2390
+ 'variant': 'a', 'text': _c.get('draft_a_text') or '',
2391
+ 'style': _c.get('draft_a_style') or '',
2392
+ 'text_en': _c.get('draft_a_text_en'),
2393
+ 'assigned_style': '$PICKED_STYLE' or None,
2394
+ 'assigned_mode': '$PICKED_MODE' or 'use',
2395
+ },
2396
+ {
2397
+ 'variant': 'b', 'text': _draft_b_text,
2398
+ 'style': _c.get('draft_b_style') or '',
2399
+ 'text_en': _c.get('draft_b_text_en'),
2400
+ 'assigned_style': '$PICKED_STYLE_B' or None,
2401
+ 'assigned_mode': '$PICKED_MODE_B' or 'use',
2402
+ },
2403
+ ]
2270
2404
  json.dump({'candidates': candidates,
2271
2405
  'session_id': '$CLAUDE_SESSION_ID',
2272
2406
  'assigned_style': '$PICKED_STYLE' or None,
@@ -2367,6 +2501,44 @@ if [ "$GEN_EXIT" -ne 0 ]; then
2367
2501
  log "WARN: twitter_gen_links.py exited $GEN_EXIT, continuing with whatever links it set (per-candidate fallback to plain project URL on gen failure)."
2368
2502
  fi
2369
2503
 
2504
+ # Two-draft cards: twitter_gen_links.py (above, locked/untouched) bakes the
2505
+ # tail link into reply_text in place for Draft A only (the canonical default),
2506
+ # it never looks at the 'drafts' array. Sync the same suffix onto Draft B so
2507
+ # switching to it on the review card still posts with the link.
2508
+ # link_tail.py's apply_tail_link is documented APPEND-ONLY (concatenates the
2509
+ # bridge sentence onto the unmodified reply_text, never rewrites it), so
2510
+ # diffing the appended suffix and re-applying it verbatim is safe.
2511
+ python3 -c "
2512
+ import json
2513
+ plan_path = '$PLAN_FILE'
2514
+ try:
2515
+ with open(plan_path) as f:
2516
+ plan = json.load(f)
2517
+ except Exception as e:
2518
+ print(f'[gen] drafts-link-sync: plan read failed: {e}')
2519
+ raise SystemExit(0)
2520
+ changed = 0
2521
+ for c in plan.get('candidates') or []:
2522
+ drafts = c.get('drafts')
2523
+ if not isinstance(drafts, list) or len(drafts) != 2:
2524
+ continue
2525
+ rec_idx = 0 # Draft A is always the canonical default reply_text mirrors
2526
+ other_idx = 1
2527
+ rec_draft = drafts[rec_idx]
2528
+ other_draft = drafts[other_idx]
2529
+ baked = (c.get('reply_text') or '')
2530
+ original = (rec_draft.get('text') or '')
2531
+ if baked and original and baked != original and baked.startswith(original):
2532
+ suffix = baked[len(original):]
2533
+ rec_draft['text'] = baked
2534
+ other_draft['text'] = (other_draft.get('text') or '') + suffix
2535
+ changed += 1
2536
+ if changed:
2537
+ with open(plan_path, 'w') as f:
2538
+ json.dump(plan, f, indent=2)
2539
+ print(f'[gen] drafts-link-sync: synced tail link onto the non-recommended draft for {changed} candidate(s)')
2540
+ " 2>&1 | tee -a "$LOG_FILE"
2541
+
2370
2542
  # --- DRAFT_ONLY gate: stop after drafting for human review (MCP manual mode) -
2371
2543
  # When DRAFT_ONLY=1 the cycle runs scan -> score -> draft -> link-gen, leaves the
2372
2544
  # fully-baked plan (links already stamped into reply_text) at $PLAN_FILE, and