@m13v/s4l 1.7.6-rc.34 → 1.7.6-rc.35

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.
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "1.7.6-rc.34",
3
- "installedAt": "2026-07-18T02:38:17.233Z"
2
+ "version": "1.7.6-rc.35",
3
+ "installedAt": "2026-07-18T02:51:25.957Z"
4
4
  }
package/mcp/manifest.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "dxt_version": "0.1",
3
3
  "name": "social-autoposter",
4
4
  "display_name": "S4L",
5
- "version": "1.7.6-rc.34",
5
+ "version": "1.7.6-rc.35",
6
6
  "description": "Draft, review, approve, and autopilot X/Twitter posts.",
7
7
  "long_description": "## **⚠️ The disclaimer above is generic Claude boilerplate.** Anthropic shows the same warning on every plugin regardless of what it does; any plugin has the same level of access as any app you download from the internet.\n\nS4L is an open source product developed by Mediar.ai Incorporated, a VC-backed San Francisco-based startup.\n\nTo get started:\n\n1\\. Copy this prompt: **Set me up on S4L plugin end to end**\n\n2\\. Quit with CMD+Q, reopen Claude, paste into a new chat.\n\nWhat happens next:\n\n* About every 5 minutes S4L scans X for posts that match your topics and drafts replies in your voice.\n* Drafts show up as review cards, usually the first within a few minutes. Nothing is posted automatically; you approve each one.\n* Posting autopilot stays off until you explicitly turn it on.",
8
8
  "author": {
package/mcp/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m13v/s4l-mcp",
3
- "version": "1.7.6-rc.34",
3
+ "version": "1.7.6-rc.35",
4
4
  "private": true,
5
5
  "description": "Desktop MCP client for social-autoposter (X/Twitter rail): manual draft/review/approve loop, autopilot control, and stats. Thin wrapper over the existing pipeline scripts.",
6
6
  "license": "MIT",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m13v/s4l",
3
- "version": "1.7.6-rc.34",
3
+ "version": "1.7.6-rc.35",
4
4
  "description": "Automated social posting pipeline for Reddit, X/Twitter, LinkedIn, and Moltbook. Install as a Claude Code agent skill.",
5
5
  "bin": {
6
6
  "social-autoposter": "bin/cli.js",
@@ -688,10 +688,14 @@ def render_reddit_prompt(ing):
688
688
  recent_self_block, arm (optional), lane (optional, promotion assumed).
689
689
  """
690
690
  arm = ing.get("arm") or os.environ.get("S4L_DRAFT_PROMPT_VARIANT") or ""
691
- # Reddit drafting is promotion-lane work by definition today (one product
692
- # project per draft call); the persona directive would reference blocks
693
- # this prompt does not carry. Force the promotion directive regardless of
694
- # any stray S4L_ACTIVE_LANE in the environment.
691
+ # Lane comes ONLY from the ingredients (post_reddit.py stamps it onto the
692
+ # discover plan at the cycle's assignment point and threads it through
693
+ # build_draft_prompt). Deliberately NOT read from S4L_ACTIVE_LANE here:
694
+ # salvage plans drafted in a later cycle must keep their own (promotion)
695
+ # directive even when THIS cycle's coin flip landed personal_brand.
696
+ # lane='personal_brand' renders the persona directive + the persona-
697
+ # whitelisted projects_json; the RD template carries every block the
698
+ # persona directive references (@CORPUS_BLOCK@ = ACCOUNT VOICE CORPUS).
695
699
  lane = ing.get("lane") or ""
696
700
 
697
701
  from engagement_styles import get_content_rules, get_voice_relationship_rule
@@ -557,7 +557,7 @@ def main() -> int:
557
557
  print(json.dumps(out), flush=True)
558
558
  return 0
559
559
 
560
- # Plugin (MCP post_drafts) flow sets S4L_SKIP_LINK_TAIL=1. The bridge only
560
+ # Plugin (MCP approve_drafts) flow sets S4L_SKIP_LINK_TAIL=1. The bridge only
561
561
  # rewords prose around the URL — the minted short link is produced by a
562
562
  # separate deterministic wrap step in twitter_post_plan.py — so the Claude
563
563
  # call buys nothing there, and on .mcpb customer boxes (no `claude` binary)
@@ -235,7 +235,7 @@ def _sync_with_backend(cands: list) -> tuple[int, int]:
235
235
  skill/run-twitter-cycle.sh) and mark it terminal here too, same as a
236
236
  human "discard all pending" would. Without this, a card can sit in
237
237
  the review queue as an approvable draft long after the backend has
238
- moved on; approving it later silently no-ops (post_drafts returns
238
+ moved on; approving it later silently no-ops (approve_drafts returns
239
239
  posted:0, no browser ever launches, no post-*.log — see the
240
240
  2026-07-09 "approved 3 cards, nothing posted" investigation).
241
241
 
@@ -246,7 +246,7 @@ def _sync_with_backend(cands: list) -> tuple[int, int]:
246
246
 
247
247
  APPROVED cards are exempt from the prune: an approval is a settled human
248
248
  decision awaiting its serialized post, and stamping terminal on it makes
249
- post_drafts refuse it as already-decided (posted:0). That exact race ate
249
+ approve_drafts refuse it as already-decided (posted:0). That exact race ate
250
250
  2 of 3 approvals on 2026-07-10 (approved 04:30:02Z, merge stamped
251
251
  backend_status_expired 04:32:33Z, poster refused both). The freshness
252
252
  gate exists to stop stale UNDECIDED drafts from burning review attention;
@@ -442,8 +442,21 @@ def _db_pick_salvage_candidates(batch_id, limit=1):
442
442
  data = (resp or {}).get("data") or {}
443
443
  if not data.get("decisions"):
444
444
  return None
445
+ # Lane for salvage rows is derived from CONFIG (persona:true on the
446
+ # row's project), not from this cycle's env: a persona row that
447
+ # failed transiently must re-draft under the persona directive even
448
+ # when the retry cycle's coin flip landed promotion, and vice versa.
449
+ _sv_proj = (data.get("project_name") or "").lower()
450
+ try:
451
+ _sv_lane = "personal_brand" if any(
452
+ p.get("persona") is True and (p.get("name") or "").lower() == _sv_proj
453
+ for p in (load_config().get("projects") or [])
454
+ ) else "promotion"
455
+ except Exception:
456
+ _sv_lane = "promotion"
445
457
  return {
446
458
  "project_name": data.get("project_name") or "general",
459
+ "lane": _sv_lane,
447
460
  # batch_id must ride the plan file into --phase draft: the
448
461
  # snapshot filename, the prompt's top_performers --invoked-by,
449
462
  # and the proposed_excludes 2-distinct-batch activation gate all
@@ -1204,7 +1217,7 @@ def _prefetch_thread_digests(candidates, reddit_username=None):
1204
1217
 
1205
1218
  def build_draft_prompt(project, config, candidates, top_report, recent_comments,
1206
1219
  style_assignment_a=None, style_assignment_b=None, digests=None,
1207
- batch_id=None, recent_self=""):
1220
+ batch_id=None, recent_self="", lane=""):
1208
1221
  """DRAFT phase: assemble reddit ingredients, render via draft_prompt_core.
1209
1222
 
1210
1223
  The prompt TEXT lives in scripts/draft_prompt_core.py, the single source
@@ -1275,6 +1288,11 @@ def build_draft_prompt(project, config, candidates, top_report, recent_comments,
1275
1288
  "style_a_name": (style_assignment_a or {}).get("style") or "style_name",
1276
1289
  "style_b_name": (style_assignment_b or {}).get("style") or "style_name",
1277
1290
  "recent_self_block": recent_self or "",
1291
+ # personal_brand renders the persona directive + persona-whitelisted
1292
+ # project JSON in draft_prompt_core; comes from the plan stamp (see
1293
+ # _discover_iteration), NEVER from env; salvage plans predate this
1294
+ # cycle's lane and must keep the promotion directive.
1295
+ "lane": lane or "",
1278
1296
  }
1279
1297
  return _dpc.render_reddit_prompt(ing)
1280
1298
 
@@ -1992,15 +2010,24 @@ def _discover_iteration(args, config, reddit_username, already_picked):
1992
2010
  builds the query bank and runs reddit_tools.cmd_search directly; no Claude
1993
2011
  session). Uses `decisions` key for downstream-phase compatibility.
1994
2012
  """
1995
- if args.project:
2013
+ # Force-inject (2026-07-17): --project (manual MCP runs) or S4L_FORCE_PROJECT
2014
+ # (the personal_brand lane's env, set by s4l_mode.py via the wrapper's eval)
2015
+ # bypasses pick_project entirely. This is the ONLY way a persona:true entry
2016
+ # can be selected; pick_project structurally excludes personas. Mirrors
2017
+ # run-twitter-cycle.sh's S4L_FORCE_PROJECT handling.
2018
+ force_project = args.project or (os.environ.get("S4L_FORCE_PROJECT") or "").strip()
2019
+ if force_project:
1996
2020
  project = None
1997
2021
  for p in config.get("projects", []):
1998
- if p["name"].lower() == args.project.lower():
2022
+ if p["name"].lower() == force_project.lower():
1999
2023
  project = p
2000
2024
  break
2001
2025
  if not project:
2002
- print(f"[post_reddit] ERROR: project '{args.project}' not found")
2026
+ print(f"[post_reddit] ERROR: project '{force_project}' not found")
2003
2027
  return None
2028
+ if not args.project:
2029
+ print(f"[post_reddit] force-inject: S4L_FORCE_PROJECT={force_project} "
2030
+ f"(lane={os.environ.get('S4L_ACTIVE_LANE') or 'promotion'})")
2004
2031
  else:
2005
2032
  project = pick_project("reddit", exclude=already_picked)
2006
2033
  if not project:
@@ -2259,8 +2286,20 @@ def _discover_iteration(args, config, reddit_username, already_picked):
2259
2286
  # batch_id rides the plan file into --phase draft (snapshot filename,
2260
2287
  # top_performers --invoked-by, proposed_excludes activation gate). Same
2261
2288
  # reason as the salvage plan's batch_id above.
2289
+ #
2290
+ # lane (2026-07-17): stamped ONCE here at plan-write time and read from
2291
+ # the PLAN by every downstream phase (_draft_iteration →
2292
+ # render_reddit_prompt), never from env; salvage plans from earlier
2293
+ # cycles and the env-less MCP approval poster must not inherit this
2294
+ # cycle's lane. Same stamp-at-source convention as active_experiments
2295
+ # (see CLAUDE.md). Derived from CONFIG (persona:true), not from
2296
+ # S4L_ACTIVE_LANE, so an env-less manual `--project <persona>` run also
2297
+ # drafts under the persona directive; the personal_brand lane can only
2298
+ # ever force-inject the persona project, so the two always agree.
2262
2299
  return {"project_name": project_name, "decisions": selected,
2263
2300
  "batch_id": queue_batch,
2301
+ "lane": ("personal_brand" if project.get("persona") is True
2302
+ else "promotion"),
2264
2303
  "cost": 0.0, "session_id": None,
2265
2304
  "phase": "discover"}
2266
2305
 
@@ -2434,7 +2473,8 @@ def _draft_iteration(plan, config, reddit_username):
2434
2473
  style_assignment_a=style_assignment_a,
2435
2474
  style_assignment_b=style_assignment_b, digests=digests,
2436
2475
  batch_id=plan.get("batch_id"),
2437
- recent_self=_recent_self)
2476
+ recent_self=_recent_self,
2477
+ lane=plan.get("lane") or "")
2438
2478
  # Durable full-prompt record per batch (same prep-prompts dir the X
2439
2479
  # cycle writes; reddit-draft-prompt-<batch>.md, newest 50 kept), so
2440
2480
  # prompt-block presence is verifiable after any release.
@@ -2580,7 +2620,7 @@ def _draft_iteration(plan, config, reddit_username):
2580
2620
  merged_d["thread_title"] = drafted_d.get("thread_title") or c.get("thread_title")
2581
2621
  merged_d["engagement_style"] = _style_a or c.get("engagement_style")
2582
2622
  # Top-level assigned_style/assigned_mode default to Draft A (the
2583
- # legacy-mirror draft); the MCP post_drafts edit path overwrites
2623
+ # legacy-mirror draft); the MCP approve_drafts edit path overwrites
2584
2624
  # these on the card when a human switches to Draft B (mirrors
2585
2625
  # twitter_post_plan.py's per-candidate override, see
2586
2626
  # _post_iteration below and index.ts's reddit approval branch).
@@ -2654,6 +2694,23 @@ def _post_iteration(plan, reddit_username):
2654
2694
  os.environ["CLAUDE_SESSION_ID"] = plan_session_id
2655
2695
 
2656
2696
  active_campaigns = load_active_reddit_campaigns()
2697
+ # Persona posts never carry a product campaign suffix (2026-07-17): the
2698
+ # reddit analog of the X lane's TWITTER_TAIL_LINK_RATE=0. Resolved from
2699
+ # config (persona:true on the plan's project), NOT from lane env; the
2700
+ # MCP approval poster runs env-less, and config is the durable truth.
2701
+ if active_campaigns:
2702
+ _plan_proj = (plan.get("project_name") or "").lower()
2703
+ try:
2704
+ _is_persona = any(
2705
+ p.get("persona") is True and (p.get("name") or "").lower() == _plan_proj
2706
+ for p in (load_config().get("projects") or [])
2707
+ )
2708
+ except Exception:
2709
+ _is_persona = False
2710
+ if _is_persona:
2711
+ print(f"[post_reddit] persona project {_plan_proj!r}: campaign "
2712
+ f"suffixes disabled for this plan")
2713
+ active_campaigns = []
2657
2714
  if active_campaigns:
2658
2715
  for c in active_campaigns:
2659
2716
  print(f"[post_reddit] active campaign id={c['id']} "
@@ -2681,7 +2738,7 @@ def _post_iteration(plan, reddit_username):
2681
2738
  # Two-draft cards (2026-07-15): each decision may carry its OWN
2682
2739
  # (assigned_style, assigned_mode) reflecting whichever draft is
2683
2740
  # actually posting — either Draft A (stamped at plan-write time) or
2684
- # Draft B (stamped by the MCP post_drafts edit path when a human
2741
+ # Draft B (stamped by the MCP approve_drafts edit path when a human
2685
2742
  # switched drafts on the review card). Without this, every card
2686
2743
  # would coerce back to the single batch-wide Draft A assignment even
2687
2744
  # when it posted under Draft B's style, silently corrupting the
@@ -148,7 +148,7 @@ def _tail_link_rate() -> float:
148
148
  # that calls this script). Force rate=1.0 there so a hand-approved draft
149
149
  # never drops the link the user already saw baked into the card text —
150
150
  # ported from the old post-time override in mcp/src/index.ts
151
- # (`TWITTER_TAIL_LINK_RATE: "1.0"` on the post_drafts path), which no
151
+ # (`TWITTER_TAIL_LINK_RATE: "1.0"` on the approve_drafts path), which no
152
152
  # longer fires now that the decision happens here, before the card is
153
153
  # ever shown. The autonomous DRAFT_ONLY=0 lane keeps running the real A/B
154
154
  # experiment at the configured rate.
@@ -169,7 +169,7 @@ def apply_tail_link(candidate: dict, link_url: str) -> None:
169
169
  gate (both the review-card path and the autonomous-post path pass through
170
170
  twitter_gen_links.py first) and already budgets long waits (SEO page-gen
171
171
  alone can take 10-40 min). twitter_post_plan.py's post-time call was a bad
172
- fit for the queue-backed pipeline: post_drafts is a synchronous MCP call
172
+ fit for the queue-backed pipeline: approve_drafts is a synchronous MCP call
173
173
  the user is actively waiting on after clicking Approve, while the
174
174
  s4l-worker scheduled task claims one job per minute and doesn't overlap a
175
175
  multi-minute drafting turn — so a queue-routed call there could stall an
@@ -313,7 +313,7 @@ def resolve_link(candidate: dict, projects: dict, page_gen_rate: float) -> tuple
313
313
  # `website`/`url` a persona project happens to carry (some installs got the
314
314
  # user's own X profile written there) must NEVER become a tail link. Enforce
315
315
  # it here at the single source so no downstream surface (review card, manual
316
- # post_drafts) has to strip a link that should never have been generated.
316
+ # approve_drafts) has to strip a link that should never have been generated.
317
317
  if proj.get("persona"):
318
318
  return ("", "persona_no_link")
319
319
  plain_url = proj.get("website") or proj.get("url") or ""
@@ -80,7 +80,7 @@ def print(*args, **kwargs): # noqa: A001 -- deliberate builtins.print shadow
80
80
  _neuter_stream(sys.stderr)
81
81
 
82
82
 
83
- # Graceful SIGTERM: the MCP post_drafts timeout (and anything else that asks
83
+ # Graceful SIGTERM: the MCP approve_drafts timeout (and anything else that asks
84
84
  # nicely before SIGKILL) sends SIGTERM. Dying instantly reopens the same
85
85
  # posted-but-unrecorded window as the broken pipe, so instead flag the loop to
86
86
  # stop at the next candidate boundary: current candidate finishes its
@@ -617,7 +617,7 @@ def post_one(c: dict, picker_assignment: dict | None = None) -> tuple[str, str]:
617
617
  # BEFORE the DRAFT_ONLY gate for every candidate, so both the review-card
618
618
  # path and the autonomous-post path already carry a stamped
619
619
  # tail_link_variant + finalized reply_text by the time they reach here.
620
- # Post time is a bad fit for the queue-backed Claude call: post_drafts is
620
+ # Post time is a bad fit for the queue-backed Claude call: approve_drafts is
621
621
  # a synchronous MCP call the user is actively waiting on after clicking
622
622
  # Approve, while the s4l-worker scheduled task claims one job per minute
623
623
  # and doesn't overlap a multi-minute drafting turn, so a queue-routed call
@@ -1127,7 +1127,7 @@ def main() -> int:
1127
1127
 
1128
1128
  posted = skipped = failed = 0
1129
1129
  # Per-candidate outcome rows for the summary JSON. The MCP layer
1130
- # (mcp/src/index.ts post_drafts) prefers summary.candidate_results over
1130
+ # (mcp/src/index.ts approve_drafts) prefers summary.candidate_results over
1131
1131
  # regex-parsing our stdout; this is the authoritative channel that marks
1132
1132
  # review-queue cards posted/terminal so a card is never re-fed to a poster
1133
1133
  # after its reply already landed (2026-07-06 duplicate-retry incident).
@@ -1262,7 +1262,7 @@ def main() -> int:
1262
1262
  # (assigned_style, assigned_mode) reflecting whichever draft is
1263
1263
  # actually posting, either the recommended one (stamped at
1264
1264
  # plan-write time) or the other one (stamped by the MCP
1265
- # post_drafts edits path when a human switched drafts on the
1265
+ # approve_drafts edits path when a human switched drafts on the
1266
1266
  # review card). Without this, every card would coerce back to
1267
1267
  # ONE cycle-wide style even when it posted under the other
1268
1268
  # style, silently corrupting the engagement_style label the
package/setup/SKILL.md CHANGED
@@ -71,7 +71,7 @@ Do not report setup complete until all of these are true:
71
71
  ## Choose the path
72
72
 
73
73
  - If the social-autoposter MCP tools are connected (`project_config`, `runtime`,
74
- `queue_setup`, `post_drafts`, `get_stats`, `dashboard`), use the MCP path.
74
+ `queue_setup`, `approve_drafts`, `get_stats`, `dashboard`), use the MCP path.
75
75
  Do not hand-edit `config.json`.
76
76
  - If only the CLI/skill is installed, use the CLI fallback.
77
77
 
@@ -210,12 +210,21 @@ trap '_sa_emit_run_summary_oneshot; _sa_release_lease_oneshot; _sa_release_locks
210
210
  BATCH_ID="rdcycle-$(date +%Y%m%d-%H%M%S)"
211
211
  log "Cycle batch_id=$BATCH_ID"
212
212
 
213
+ # --- Lane env (2026-07-17, mirrors run-draft-and-publish.sh) ----------------
214
+ # personal_brand lane force-injects the persona project into the discover
215
+ # phase (S4L_FORCE_PROJECT + S4L_ACTIVE_LANE=personal_brand); promotion-only
216
+ # exports S4L_CYCLE_LANE=promotion and nothing else (normal weighted pick).
217
+ # Both lanes on = weighted per-cycle coin flip inside s4l_mode.py, same as X.
218
+ # Discover STAMPS the lane onto the plan JSON (post_reddit.py), so the draft
219
+ # phase and the env-less MCP approval poster read the plan, never this env.
220
+ eval "$(python3 "$REPO_DIR/scripts/s4l_mode.py" env 2>/dev/null || true)"
221
+
213
222
  # --- Draft-only mode (2026-07-14, mirrors run-draft-and-publish.sh) ---------
214
223
  # The ONE mode flag (scripts/s4l_mode.py draft-only, stdout 1/0) now governs
215
224
  # Reddit exactly like the X cycle: when ON, both lanes stop after the draft
216
225
  # phase and merge their drafted decisions into the menu-bar review cards
217
226
  # (merge_review_queue.py --reddit-plan); posting then happens one approved
218
- # card at a time via the plugin's post_drafts tool, which reconstructs a
227
+ # card at a time via the plugin's approve_drafts tool, which reconstructs a
219
228
  # one-decision plan and reuses `post_reddit.py --phase post` unchanged. When
220
229
  # OFF (operator opt-out), the lanes post autonomously below as always.
221
230
  # Fail-safe default is 1 (draft-only) — same posture as the X wrapper.
@@ -1741,7 +1741,7 @@ fi
1741
1741
  # scan_candidates tool reads this so a Claude Desktop scheduled-task session can do
1742
1742
  # the drafting ITSELF (on the user's plan, no `claude -p`) and hand the drafts back
1743
1743
  # via submit_drafts. Candidates stay 'pending' (drafted+posted via submit_drafts ->
1744
- # post_drafts, or salvaged by a later cycle). The browser lock was already released
1744
+ # approve_drafts, or salvaged by a later cycle). The browser lock was already released
1745
1745
  # at the Phase 1 handoff, so this exits clean via the EXIT trap. NO current caller
1746
1746
  # sets SCAN_ONLY, so the autopilot/draft_cycle paths are byte-for-byte unchanged.
1747
1747
  if [ "${SCAN_ONLY:-0}" = "1" ]; then