@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.
- package/bin/cli.js +23 -0
- package/mcp/dist/index.js +332 -24
- package/mcp/dist/onboarding.js +3 -10
- package/mcp/dist/panel.html +42 -31
- package/mcp/dist/product-link.html +1 -1
- package/mcp/dist/runtime.js +101 -1
- package/mcp/dist/telemetry.js +80 -3
- package/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +9 -1
- package/mcp/menubar/dashboard_server.py +12 -0
- package/mcp/menubar/s4l_card.py +472 -96
- package/mcp/menubar/s4l_menubar.py +269 -26
- package/mcp/menubar/s4l_state.py +169 -23
- package/mcp/package.json +1 -1
- package/mcp/shared/doctor.cjs +8 -0
- package/mcp/shared/onboarding-ledger.cjs +1 -0
- package/package.json +3 -1
- package/scripts/active_users.py +3 -6
- package/scripts/autopilot_stall_watch.py +200 -16
- package/scripts/claude_job.py +6 -0
- package/scripts/feedback_digest.py +34 -6
- package/scripts/get-latest-staging-mcpb.sh +50 -0
- package/scripts/harness_overlay.py +45 -0
- package/scripts/identity.py +26 -0
- package/scripts/learned_preferences.py +241 -32
- package/scripts/link_tail.py +167 -91
- package/scripts/mark_event.py +128 -0
- package/scripts/memory_snapshot.py +62 -0
- package/scripts/pick_twitter_thread_target.py +1 -1
- package/scripts/release-mcpb.sh +48 -0
- package/scripts/reset-test-machine.sh +85 -3
- package/scripts/s4l_mode.py +7 -5
- package/scripts/scheduled_tasks_snapshot.py +107 -0
- package/scripts/send_gmail_report.py +76 -0
- package/scripts/sentry_digest.py +81 -189
- package/scripts/sentry_init.py +43 -15
- package/scripts/setup_twitter_auth.py +76 -38
- package/scripts/snapshot.py +20 -0
- package/scripts/twitter_post_plan.py +39 -13
- package/skill/dm-outreach-twitter.sh +19 -0
- package/skill/engage-twitter.sh +27 -0
- package/skill/run-draft-and-publish.sh +13 -12
- package/skill/run-linkedin.sh +9 -1
- package/skill/run-twitter-cycle.sh +199 -27
- 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
|
-
|
|
429
|
-
|
|
430
|
-
|
|
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).
|
|
1035
|
-
#
|
|
1036
|
-
#
|
|
1037
|
-
#
|
|
1038
|
-
#
|
|
1039
|
-
#
|
|
1040
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
package/skill/engage-twitter.sh
CHANGED
|
@@ -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
|
|
127
|
-
# plist's baked DRAFT_ONLY=1 baseline:
|
|
128
|
-
# - draft-only ON (default)
|
|
129
|
-
#
|
|
130
|
-
# - draft-only OFF
|
|
131
|
-
# top-1 pick autonomously
|
|
132
|
-
# as the no-human quality
|
|
133
|
-
#
|
|
134
|
-
#
|
|
135
|
-
#
|
|
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 [ "$
|
|
138
|
+
if [ "$DRAFT_ONLY_FLAG" = "0" ]; then
|
|
138
139
|
export DRAFT_ONLY=0
|
|
139
|
-
echo "[run-draft-and-publish] draft-only OFF:
|
|
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
|
package/skill/run-linkedin.sh
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
2013
|
-
#
|
|
2014
|
-
#
|
|
2015
|
-
#
|
|
2016
|
-
#
|
|
2017
|
-
#
|
|
2018
|
-
#
|
|
2019
|
-
|
|
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
|
|
2026
|
-
3. Persist
|
|
2027
|
-
4. Emit a structured plan describing the chosen candidates,
|
|
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
|
-
##
|
|
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
|
|
2057
|
-
-
|
|
2058
|
-
|
|
2059
|
-
|
|
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
|
|
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
|
|
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
|
-
-
|
|
2072
|
-
-
|
|
2073
|
-
-
|
|
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
|