@m13v/s4l 1.7.9-rc.2 → 1.7.9

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.9-rc.2",
3
- "installedAt": "2026-08-09T00:22:23.419Z"
2
+ "version": "1.7.9",
3
+ "installedAt": "2026-08-10T22:28:09.611Z"
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.9-rc.2",
5
+ "version": "1.7.9",
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": {
@@ -10,14 +10,17 @@
10
10
  "email": "i@m13v.com",
11
11
  "url": "https://s4l.ai"
12
12
  },
13
+ "compatibility": {
14
+ "platforms": [
15
+ "darwin"
16
+ ]
17
+ },
13
18
  "server": {
14
19
  "type": "node",
15
20
  "entry_point": "dist/index.js",
16
21
  "mcp_config": {
17
- "command": "${__dirname}/vendor/node-darwin-arm64/bin/node",
18
- "args": [
19
- "${__dirname}/dist/index.js"
20
- ],
22
+ "command": "${__dirname}/launch.js",
23
+ "args": [],
21
24
  "env": {
22
25
  "PATH": "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
23
26
  }
@@ -158,14 +158,12 @@ class _Worker(threading.Thread):
158
158
  # unaffected (CDP is offscreen-raster + synthetic input; the occlusion
159
159
  # flags keep hidden tabs painting).
160
160
  # DEFAULT ON (user decision 2026-08-07, reverses the 2026-07-30 default-off
161
- # call): the guard hides an offscreen harness that steals the foreground
162
- # unless explicitly opted out. Two knobs, by design:
163
- # S4L_NO_HARNESS_HIDE -> HARD override, force off, checked FIRST so it
164
- # always wins (this is the one-time interactive-login escape hatch).
165
- # S4L_HARNESS_HIDE=0 -> SOFT opt-out for a machine that wants it off.
166
- # Unset or any non-"0" value => hide is active (the new default).
167
- if os.environ.get("S4L_NO_HARNESS_HIDE"):
168
- return
161
+ # call). ONE flag by design (2026-08-08 the old S4L_NO_HARNESS_HIDE
162
+ # override was removed to end the double-flag confusion):
163
+ # S4L_HARNESS_HIDE unset / any value except "0" => hide active (default)
164
+ # S4L_HARNESS_HIDE=0 => disabled the single
165
+ # off switch (e.g. to keep a harness window visible for a one-time
166
+ # interactive login, then set it back to 1 / unset it).
169
167
  if os.environ.get("S4L_HARNESS_HIDE", "1") == "0":
170
168
  return
171
169
  pos = details.get("window_position") or ""
package/mcp/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m13v/s4l-mcp",
3
- "version": "1.7.9-rc.2",
3
+ "version": "1.7.9",
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.9-rc.2",
3
+ "version": "1.7.9",
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",
@@ -32,7 +32,12 @@ log() { echo "[s4l-install] $*"; }
32
32
  die() { echo "[s4l-install] ERROR: $*" >&2; exit 1; }
33
33
 
34
34
  [ "$(uname -s)" = "Darwin" ] || die "macOS only for now."
35
- [ "$(uname -m)" = "arm64" ] || die "Apple Silicon only for now (the bundle vendors a darwin-arm64 Node)."
35
+ # The bundle vendors both darwin-arm64 and darwin-x64 Node binaries (1.7.9+);
36
+ # pick the one matching this machine.
37
+ case "$(uname -m)" in
38
+ x86_64|amd64) NODE_ARCH="x64" ;;
39
+ *) NODE_ARCH="arm64" ;;
40
+ esac
36
41
 
37
42
  URL="$STABLE_URL"
38
43
  [ "${S4L_CHANNEL:-}" = "staging" ] && URL="$STAGING_URL"
@@ -45,7 +50,9 @@ log "downloading $URL"
45
50
  mkdir -p "$TMP/app"
46
51
  /usr/bin/unzip -q "$TMP/s4l.mcpb" -d "$TMP/app" || die "unzip failed"
47
52
  [ -f "$TMP/app/dist/index.js" ] || die "bundle missing dist/index.js (bad download?)"
48
- [ -x "$TMP/app/vendor/node-darwin-arm64/bin/node" ] || die "bundle missing vendored node"
53
+ # Older bundles (pre-1.7.9) only vendor arm64; accept either so a staging box
54
+ # pinned to an old release still installs on Apple Silicon.
55
+ [ -x "$TMP/app/vendor/node-darwin-$NODE_ARCH/bin/node" ] || [ -x "$TMP/app/vendor/node-darwin-arm64/bin/node" ] || die "bundle missing vendored node for $NODE_ARCH"
49
56
 
50
57
  mkdir -p "$STATE_DIR"
51
58
  rm -rf "$APP_DIR.new"
@@ -55,7 +62,8 @@ mv "$APP_DIR.new" "$APP_DIR"
55
62
  rm -rf "$APP_DIR.old"
56
63
  log "installed bundle to $APP_DIR"
57
64
 
58
- NODE="$APP_DIR/vendor/node-darwin-arm64/bin/node"
65
+ NODE="$APP_DIR/vendor/node-darwin-$NODE_ARCH/bin/node"
66
+ [ -x "$NODE" ] || NODE="$APP_DIR/vendor/node-darwin-arm64/bin/node"
59
67
  SERVER="$APP_DIR/dist/index.js"
60
68
  SAFE_PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"
61
69
 
@@ -35,6 +35,18 @@ Usage (CLI / from twitter_post_plan.py):
35
35
  --project "studyly" \\
36
36
  --platform "twitter"
37
37
 
38
+ # 'name' CTA arm (twitter-tail-cta experiment, launched 2026-08-08): end
39
+ # the bridge with the PLAIN product name instead of a tracked URL. No link,
40
+ # not clickable — intentional. Requires --product-name (or a resolvable
41
+ # config.json entry for --project); no --link-url is used.
42
+ python3 link_tail.py \\
43
+ --reply-text "Step 2 CK was the one that burned me out worst..." \\
44
+ --cta-mode name \\
45
+ --product-name "Studyly" \\
46
+ --thread-text "huge milestone, just passed step 2..." \\
47
+ --project "studyly" \\
48
+ --platform "twitter"
49
+
38
50
  Stdout (single JSON object):
39
51
  {"ok": true, "text": "<reply_text with bridge tail + URL>",
40
52
  "tail": "<just the bridge sentence with URL>",
@@ -137,6 +149,26 @@ def enforce_budget(text: str, link_url: str,
137
149
  return _trim_to_chars(text, limit), True
138
150
 
139
151
 
152
+ def enforce_budget_name(text: str, product_name: str,
153
+ limit: int = TWEET_LIMIT) -> tuple[str, bool]:
154
+ """Name-arm analogue of enforce_budget: guarantee x_weighted_len(text) <=
155
+ limit by trimming the BODY, never the trailing plain product name. Unlike
156
+ the URL arm there is no flat t.co weighting — the product name counts as
157
+ its own real length. Returns (text, was_trimmed)."""
158
+ if x_weighted_len(text) <= limit:
159
+ return text, False
160
+ stripped = text.rstrip()
161
+ if product_name and stripped.endswith(product_name):
162
+ head = stripped[: stripped.rfind(product_name)].rstrip()
163
+ # Budget left for the body after reserving the product name + a joining
164
+ # space.
165
+ max_head = limit - x_weighted_len(product_name) - 1
166
+ trimmed_head = _trim_to_chars(head, max_head)
167
+ return (trimmed_head + " " + product_name).strip(), True
168
+ # No product name at the tail (shouldn't happen): hard word-trim.
169
+ return _trim_to_chars(text, limit), True
170
+
171
+
140
172
  def _load_project_entry(project: str) -> dict:
141
173
  """Load the matched project's raw config.json entry (case-insensitive
142
174
  name match), or {} if config.json is unreadable or the project is
@@ -174,6 +206,28 @@ def resolve_learned_preferences_block(project_entry: dict) -> str:
174
206
  no entries yet."""
175
207
  return learned_preferences.prompt_block(project_entry)
176
208
 
209
+
210
+ def resolve_product_name(project_entry: dict, override: str | None,
211
+ project: str = "") -> str:
212
+ """Plain-text product name for the 'name' CTA arm (twitter-tail-cta).
213
+
214
+ Priority: an explicit --product-name override, then the config.json
215
+ entry's `product_name`, then its `name`, then a title-cased fallback
216
+ derived from the --project arg (so we always have SOMETHING to plant even
217
+ for a project missing from config.json). The name arm has nothing to
218
+ append otherwise, so this deliberately never returns empty when a project
219
+ string is present.
220
+ """
221
+ if override and override.strip():
222
+ return override.strip()
223
+ pn = (project_entry.get("product_name") or "").strip()
224
+ if pn:
225
+ return pn
226
+ nm = (project_entry.get("name") or "").strip()
227
+ if nm:
228
+ return nm
229
+ return (project or "").replace("_", " ").replace("-", " ").strip().title()
230
+
177
231
  # Paths to the Claude Code CLI in order of preference. run_claude.sh resolves
178
232
  # `claude` from PATH; we fall back to a direct nvm path if PATH lookup fails
179
233
  # (twitter_post_plan.py is invoked from a launchd shell that may have a thin
@@ -207,9 +261,18 @@ def build_prompt(*, reply_text: str, link_url: str, thread_text: str,
207
261
  project: str, platform: str,
208
262
  voice_relationship: str = "first_party",
209
263
  learned_prefs_block: str = "",
210
- bridge_budget: int | None = None) -> str:
264
+ bridge_budget: int | None = None,
265
+ cta_mode: str = "url", product_name: str = "") -> str:
211
266
  """Compose the one-shot prompt for the bridge sentence ONLY.
212
267
 
268
+ `cta_mode` selects how the bridge ENDS (twitter-tail-cta experiment,
269
+ launched 2026-08-08):
270
+ - "url" (default): the sentence ends with the tracked landing-page URL,
271
+ the historical behavior — unchanged, byte-for-byte.
272
+ - "name": the sentence ends with the PLAIN product name (`product_name`)
273
+ as ordinary text, NOT a link. No URL, no "http", no domain. This plants
274
+ brand awareness without spending a raw tracked link's engagement cost.
275
+
213
276
  The model is never asked to reproduce or rewrite the reply already
214
277
  drafted; it's shown that text for context and told explicitly not to
215
278
  repeat it. main() appends whatever comes back to the UNMODIFIED
@@ -256,6 +319,21 @@ def build_prompt(*, reply_text: str, link_url: str, thread_text: str,
256
319
  f" - \"we ship the same recall-on-revisit pattern in {project}, scores against a 4-axis rubric, {link_url}\""
257
320
  )
258
321
 
322
+ # Name arm: replace the URL-ending example sentences with product-NAME-ending
323
+ # ones (voice-matched, same form). The bridge must END with the plain
324
+ # product name as ordinary text \u2014 no URL anywhere.
325
+ if cta_mode == "name":
326
+ if voice_relationship == "third_party":
327
+ example_block = (
328
+ f" - \"fwiw the rubric scoring on rephrased stems is what {project} ships, {product_name}\"\n"
329
+ f" - \"same recall-on-revisit pattern, scored against a 4-axis rubric, that's {product_name}\""
330
+ )
331
+ else:
332
+ example_block = (
333
+ f" - \"fwiw the rubric scoring on rephrased stems is what we built {product_name} for\"\n"
334
+ f" - \"we ship the same recall-on-revisit pattern, scored against a 4-axis rubric, in {product_name}\""
335
+ )
336
+
259
337
  # X-specific hard length budget. The reply's body is FIXED (never resized
260
338
  # by the model); only the bridge sentence's own budget matters, computed
261
339
  # by the caller from how much room the already-drafted body leaves.
@@ -263,14 +341,23 @@ def build_prompt(*, reply_text: str, link_url: str, thread_text: str,
263
341
  # no tight cap there.
264
342
  length_rule = ""
265
343
  if platform == "twitter" and bridge_budget is not None:
266
- length_rule = (
267
- f"HARD LENGTH LIMIT for YOUR SENTENCE ONLY (X counts EVERY link as "
268
- f"exactly {URL_WEIGHT} characters, no matter how long it looks; "
269
- f"that's already reserved below):\n"
270
- f"- Everything you write BEFORE the URL must be \u2264 {max(bridge_budget, 0)} "
271
- f"characters. The reply above is fixed length; you are not resizing "
272
- f"it, only adding a short sentence after it.\n"
273
- )
344
+ if cta_mode == "name":
345
+ length_rule = (
346
+ f"HARD LENGTH LIMIT for YOUR SENTENCE ONLY:\n"
347
+ f"- Everything you write, INCLUDING the product name at the end, "
348
+ f"must be \u2264 {max(bridge_budget, 0)} characters. The reply above is "
349
+ f"fixed length; you are not resizing it, only adding a short "
350
+ f"sentence after it.\n"
351
+ )
352
+ else:
353
+ length_rule = (
354
+ f"HARD LENGTH LIMIT for YOUR SENTENCE ONLY (X counts EVERY link as "
355
+ f"exactly {URL_WEIGHT} characters, no matter how long it looks; "
356
+ f"that's already reserved below):\n"
357
+ f"- Everything you write BEFORE the URL must be \u2264 {max(bridge_budget, 0)} "
358
+ f"characters. The reply above is fixed length; you are not resizing "
359
+ f"it, only adding a short sentence after it.\n"
360
+ )
274
361
 
275
362
  learned_prefs_section = ""
276
363
  if learned_prefs_block:
@@ -284,6 +371,42 @@ def build_prompt(*, reply_text: str, link_url: str, thread_text: str,
284
371
  "your job here.\n"
285
372
  )
286
373
 
374
+ if cta_mode == "name":
375
+ return f"""You are writing ONLY the bridge sentence that gets APPENDED after a social media reply we already drafted (shown below). This is a one-shot task. Output ONLY the bridge sentence itself: no preamble, no explanation, no quotes, and do NOT repeat, paraphrase, or rewrite any part of the reply below \u2014 it is already final and will be appended before your output automatically, verbatim.
376
+
377
+ PLATFORM: {platform}
378
+ PROJECT: {project}
379
+ PRODUCT NAME: {product_name}
380
+
381
+ {voice_rule}
382
+
383
+ {learned_prefs_section}
384
+
385
+ {length_rule}
386
+
387
+ ORIGINAL THREAD WE ARE REPLYING TO:
388
+ {thread_text}
389
+
390
+ REPLY ALREADY DRAFTED (fixed, DO NOT repeat or modify \u2014 your sentence is appended after this automatically):
391
+ {reply_text}
392
+
393
+ YOUR TASK:
394
+ Write ONE bridge sentence (\u2264 22 words) that:
395
+ 1. References the SINGLE strongest specific claim, mechanism, or detail from the reply above (e.g. "rephrasing on revisit", "a 4-axis rubric", "200ms p95", "automatic distractor scoring") \u2014 pick ONE concrete thing, not a category.
396
+ 2. Names a CONCRETE PRODUCT MECHANISM that delivers it (verb + noun, inferred from the project context). Do NOT say "a tool for this", "something that helps", "made this for it" \u2014 those are banned.
397
+ 3. Ends with the plain product name "{product_name}" written as ORDINARY TEXT \u2014 NOT a link. This is intentional and required: do NOT include any URL, any "http" or "https", any "www", and no domain (nothing that looks like "name.com" / "name.io"). No "click here", "check it out", "give it a try".
398
+ 4. Reads in the voice of the reply above (lowercase if reply is lowercase, casual if reply is casual).
399
+ 5. Obeys the VOICE RELATIONSHIP rule above. This rule overrides any default phrasing instinct.
400
+ 6. Obeys the LEARNED PREFERENCES above when present. This rule overrides the example sentences below on conflict.
401
+
402
+ OUTPUT FORMAT (strict):
403
+ Output ONLY your bridge sentence, ending in the plain product name "{product_name}", on a single line. Nothing else: no JSON, no markdown, no quotes, no URL, and absolutely none of the reply text shown above.
404
+
405
+ Example bridge sentences (do NOT copy verbatim \u2014 these are FORM examples, voice-matched to this project, each ending in the product NAME, no link):
406
+ {example_block}
407
+
408
+ Write the bridge sentence now."""
409
+
287
410
  return f"""You are writing ONLY the bridge sentence that gets APPENDED after a social media reply we already drafted (shown below). This is a one-shot task. Output ONLY the bridge sentence itself: no preamble, no explanation, no quotes, and do NOT repeat, paraphrase, or rewrite any part of the reply below \u2014 it is already final and will be appended before your output automatically, verbatim.
288
411
 
289
412
  PLATFORM: {platform}
@@ -465,9 +588,15 @@ THIRD_PARTY_VOICE_VIOLATIONS = (
465
588
  )
466
589
 
467
590
 
468
- def passes_quality_gate(bridge: str, link_url: str,
591
+ # Name-arm URL guards: the 'name' CTA must NOT contain any link. Reject a
592
+ # full URL or a bare domain (so the model can't sneak the tracked link back in
593
+ # under the product-name arm).
594
+ _BARE_DOMAIN_RE = re.compile(r"\b[a-z0-9-]+\.[a-z]{2,}\b", re.IGNORECASE)
595
+
596
+
597
+ def passes_quality_gate(bridge: str, mention: str, *,
469
598
  voice_relationship: str = "first_party",
470
- ) -> tuple[bool, str]:
599
+ cta_mode: str = "url") -> tuple[bool, str]:
471
600
  """Return (passes, reason_if_not).
472
601
 
473
602
  Validates the model-written BRIDGE SENTENCE ALONE. reply_text is trusted,
@@ -475,28 +604,53 @@ def passes_quality_gate(bridge: str, link_url: str,
475
604
  the append-only contract means reply_text can't fail this gate, only the
476
605
  newly generated bridge can.
477
606
 
478
- Hard rules:
479
- - must contain link_url
480
- - must end with link_url (allow trailing whitespace, nothing else)
607
+ `mention` is the CTA target the bridge must end with: the landing-page URL
608
+ in url mode, the plain product name in name mode.
609
+
610
+ url-mode hard rules (unchanged):
611
+ - must contain the URL
612
+ - must end with the URL (allow trailing whitespace, nothing else)
481
613
  - must NOT contain banned phrases
482
- - must not be wildly longer than a sentence (MAX_BRIDGE_CHARS): the
483
- model ignored the "bridge sentence only" contract and is attempting
484
- a full rewrite — a structural violation, not a phrasing nitpick
614
+ - must not be wildly longer than a sentence (MAX_BRIDGE_CHARS)
485
615
  - on third_party projects, must NOT use first-person-plural product
486
- ownership phrases ("we ship", "we built", "our product", ...). The
487
- link_tail prompt selects voice-matched examples but the model can
488
- still drift; on violation we fall back to the mechanical concat so
489
- the post still ships without impersonating the client (root cause of
490
- the 2026-05-27 Agora OODAO incident).
616
+ ownership phrases (root cause of the 2026-05-27 Agora OODAO incident).
617
+
618
+ name-mode hard rules:
619
+ - must CONTAIN the product name
620
+ - must NOT contain any URL or bare domain (the model may not smuggle the
621
+ tracked link back into the no-link arm)
622
+ - same banned-phrase, third_party-voice, and MAX_BRIDGE_CHARS checks.
491
623
  """
492
624
  if not bridge:
493
625
  return (False, "empty")
494
- if link_url not in bridge:
626
+
627
+ if cta_mode == "name":
628
+ if mention and mention not in bridge:
629
+ return (False, "no_product_name")
630
+ if re.search(r"https?://", bridge, re.IGNORECASE):
631
+ return (False, "url_in_name_arm")
632
+ if _BARE_DOMAIN_RE.search(bridge):
633
+ return (False, "domain_in_name_arm")
634
+ lower = bridge.lower()
635
+ for phrase in BANNED_PHRASES:
636
+ if phrase in lower:
637
+ return (False, f"banned_phrase: {phrase!r}")
638
+ if voice_relationship == "third_party":
639
+ for rx in THIRD_PARTY_VOICE_VIOLATIONS:
640
+ m = rx.search(bridge)
641
+ if m:
642
+ return (False, f"third_party_voice_violation: {m.group(0)!r}")
643
+ if len(bridge) > MAX_BRIDGE_CHARS:
644
+ return (False, f"bridge_too_long:{len(bridge)}>{MAX_BRIDGE_CHARS}")
645
+ return (True, "")
646
+
647
+ # url mode (mention == link_url) — behavior unchanged.
648
+ if mention not in bridge:
495
649
  return (False, "no_url")
496
650
  # Trailing-URL check (nothing meaningful after URL, optional ./! is fine
497
651
  # to strip; but our prompt forbids trailing period — so just check no
498
652
  # alphanumeric content follows).
499
- tail = bridge.split(link_url, 1)[1].strip()
653
+ tail = bridge.split(mention, 1)[1].strip()
500
654
  if tail and re.search(r"[A-Za-z0-9]", tail):
501
655
  return (False, f"content_after_url: {tail[:40]!r}")
502
656
  lower = bridge.lower()
@@ -519,12 +673,26 @@ def mechanical_fallback(reply_text: str, link_url: str) -> str:
519
673
  return f"{reply_text} {link_url}".strip() if link_url else reply_text
520
674
 
521
675
 
676
+ def mechanical_fallback_name(reply_text: str, product_name: str) -> str:
677
+ """Name-arm analogue of mechanical_fallback: append the plain product name
678
+ (no URL) when the model call is skipped or fails."""
679
+ return f"{reply_text} {product_name}".strip() if product_name else reply_text
680
+
681
+
522
682
  def main() -> int:
523
683
  ap = argparse.ArgumentParser()
524
684
  ap.add_argument("--reply-text", required=True,
525
685
  help="The reply we already drafted (no link).")
526
- ap.add_argument("--link-url", required=True,
527
- help="The landing page URL to fold in.")
686
+ ap.add_argument("--link-url", default="",
687
+ help="The landing page URL to fold in (url mode).")
688
+ ap.add_argument("--cta-mode", default="url", choices=["url", "name"],
689
+ help="How the bridge ends: 'url' (tracked landing-page "
690
+ "link, default) or 'name' (plain product name, no "
691
+ "URL — twitter-tail-cta experiment).")
692
+ ap.add_argument("--product-name", default=None,
693
+ help="Override the product name used in the 'name' CTA "
694
+ "arm. Defaults to config.json product_name/name for "
695
+ "--project, else a title-cased --project slug.")
528
696
  ap.add_argument("--thread-text", default="",
529
697
  help="The original thread / tweet we are replying to.")
530
698
  ap.add_argument("--project", required=True,
@@ -542,19 +710,51 @@ def main() -> int:
542
710
  help="Skip run_claude.sh; call claude directly. For testing.")
543
711
  args = ap.parse_args()
544
712
 
713
+ cta_mode = args.cta_mode
545
714
  reply_text = (args.reply_text or "").strip()
546
715
  link_url = (args.link_url or "").strip()
547
- if not reply_text or not link_url:
548
- # Garbage in → mechanical concat (which respects empty link_url).
549
- out = {
716
+ project_entry = _load_project_entry(args.project)
717
+ product_name = ""
718
+ if cta_mode == "name":
719
+ product_name = resolve_product_name(
720
+ project_entry, args.product_name, args.project)
721
+ # `mention` is the CTA target the bridge must end with, threaded through
722
+ # every output/fallback path: the URL in url mode, the product name in
723
+ # name mode.
724
+ mention = product_name if cta_mode == "name" else link_url
725
+
726
+ def _mech() -> str:
727
+ return (mechanical_fallback_name(reply_text, product_name)
728
+ if cta_mode == "name"
729
+ else mechanical_fallback(reply_text, link_url))
730
+
731
+ def _enforce(text: str, lim: int) -> tuple[str, bool]:
732
+ if cta_mode == "name":
733
+ return enforce_budget_name(text, product_name, lim)
734
+ return enforce_budget(text, link_url, lim)
735
+
736
+ def _emit(out: dict) -> None:
737
+ # Stamp the CTA mode on every object; name mode also carries the
738
+ # product name under "mention" for downstream logging.
739
+ out["cta_mode"] = cta_mode
740
+ if cta_mode == "name":
741
+ out["mention"] = product_name
742
+ print(json.dumps(out), flush=True)
743
+
744
+ # url mode requires reply_text + link_url; name mode requires
745
+ # reply_text + product_name.
746
+ missing = (not reply_text) or (
747
+ (not product_name) if cta_mode == "name" else (not link_url))
748
+ if missing:
749
+ # Garbage in → mechanical concat (respects an empty CTA target).
750
+ _emit({
550
751
  "ok": True,
551
- "text": mechanical_fallback(reply_text, link_url),
552
- "tail": link_url,
752
+ "text": _mech(),
753
+ "tail": mention,
553
754
  "model_call_ok": False,
554
755
  "fallback_used": True,
555
756
  "error": "missing_input",
556
- }
557
- print(json.dumps(out), flush=True)
757
+ })
558
758
  return 0
559
759
 
560
760
  # Plugin (MCP approve_drafts) flow sets S4L_SKIP_LINK_TAIL=1. The bridge only
@@ -567,50 +767,50 @@ def main() -> int:
567
767
  # the bridge sentence.
568
768
  if os.environ.get("S4L_SKIP_LINK_TAIL") == "1":
569
769
  limit = TWEET_LIMIT if args.platform == "twitter" else None
570
- fb_text, fb_trim = enforce_budget(
571
- mechanical_fallback(reply_text, link_url), link_url,
572
- limit if limit is not None else TWEET_LIMIT * 100)
573
- out = {
770
+ fb_text, fb_trim = _enforce(
771
+ _mech(), limit if limit is not None else TWEET_LIMIT * 100)
772
+ _emit({
574
773
  "ok": True,
575
774
  "text": fb_text,
576
- "tail": link_url,
775
+ "tail": mention,
577
776
  "model_call_ok": False,
578
777
  "fallback_used": True,
579
778
  "budget_trimmed": fb_trim,
580
779
  "error": "skipped_plugin_flow",
581
780
  "elapsed_sec": 0.0,
582
- }
583
- print(json.dumps(out), flush=True)
781
+ })
584
782
  return 0
585
783
 
586
- project_entry = _load_project_entry(args.project)
587
784
  voice_relationship = args.voice_relationship or resolve_voice_relationship(project_entry)
588
785
  learned_prefs_block = resolve_learned_preferences_block(project_entry)
589
786
  # Length cap is X-specific; reddit/linkedin pass None (no tail trim).
590
787
  limit = TWEET_LIMIT if args.platform == "twitter" else None
591
788
 
592
789
  # Deterministic no-room guard: if the already-drafted reply alone leaves
593
- # no meaningful space for a bridge sentence before the (flat-weighted)
594
- # URL, skip the Claude call outright — a squeezed-in fragment is worse
595
- # than no bridge — and fall back to the mechanical concat like any other
596
- # failure path.
790
+ # no meaningful space for a bridge sentence before the CTA target, skip
791
+ # the Claude call outright — a squeezed-in fragment is worse than no
792
+ # bridge — and fall back to the mechanical concat like any other failure
793
+ # path. url mode reserves the flat 23-char URL weight; name mode reserves
794
+ # the product name's real length.
597
795
  bridge_budget = None
598
796
  if limit is not None:
599
- bridge_budget = limit - URL_WEIGHT - 1 - x_weighted_len(reply_text)
797
+ if cta_mode == "name":
798
+ bridge_budget = (limit - x_weighted_len(product_name) - 1
799
+ - x_weighted_len(reply_text))
800
+ else:
801
+ bridge_budget = limit - URL_WEIGHT - 1 - x_weighted_len(reply_text)
600
802
  if bridge_budget < MIN_BRIDGE_CHARS:
601
- fb_text, fb_trim = enforce_budget(
602
- mechanical_fallback(reply_text, link_url), link_url, limit)
603
- out = {
803
+ fb_text, fb_trim = _enforce(_mech(), limit)
804
+ _emit({
604
805
  "ok": True,
605
806
  "text": fb_text,
606
- "tail": link_url,
807
+ "tail": mention,
607
808
  "model_call_ok": False,
608
809
  "fallback_used": True,
609
810
  "budget_trimmed": fb_trim,
610
811
  "error": f"no_room_for_bridge:{bridge_budget}",
611
812
  "elapsed_sec": 0.0,
612
- }
613
- print(json.dumps(out), flush=True)
813
+ })
614
814
  return 0
615
815
 
616
816
  prompt = build_prompt(
@@ -620,6 +820,7 @@ def main() -> int:
620
820
  voice_relationship=voice_relationship,
621
821
  learned_prefs_block=learned_prefs_block,
622
822
  bridge_budget=bridge_budget,
823
+ cta_mode=cta_mode, product_name=product_name,
623
824
  )
624
825
 
625
826
  started = time.time()
@@ -628,41 +829,38 @@ def main() -> int:
628
829
  elapsed = round(time.time() - started, 2)
629
830
 
630
831
  if not ok:
631
- fb_text, fb_trim = enforce_budget(
632
- mechanical_fallback(reply_text, link_url), link_url,
633
- limit if limit is not None else TWEET_LIMIT * 100)
634
- out = {
832
+ fb_text, fb_trim = _enforce(
833
+ _mech(), limit if limit is not None else TWEET_LIMIT * 100)
834
+ _emit({
635
835
  "ok": True,
636
836
  "text": fb_text,
637
- "tail": link_url,
837
+ "tail": mention,
638
838
  "model_call_ok": False,
639
839
  "fallback_used": True,
640
840
  "budget_trimmed": fb_trim,
641
841
  "error": err or "model_call_failed",
642
842
  "elapsed_sec": elapsed,
643
- }
644
- print(json.dumps(out), flush=True)
843
+ })
645
844
  return 0
646
845
 
647
846
  bridge = clean_output(raw)
648
- passes, reason = passes_quality_gate(bridge, link_url,
649
- voice_relationship=voice_relationship)
847
+ passes, reason = passes_quality_gate(
848
+ bridge, mention, voice_relationship=voice_relationship,
849
+ cta_mode=cta_mode)
650
850
  if not passes:
651
- fb_text, fb_trim = enforce_budget(
652
- mechanical_fallback(reply_text, link_url), link_url,
653
- limit if limit is not None else TWEET_LIMIT * 100)
654
- out = {
851
+ fb_text, fb_trim = _enforce(
852
+ _mech(), limit if limit is not None else TWEET_LIMIT * 100)
853
+ _emit({
655
854
  "ok": True,
656
855
  "text": fb_text,
657
- "tail": link_url,
856
+ "tail": mention,
658
857
  "model_call_ok": True,
659
858
  "fallback_used": True,
660
859
  "budget_trimmed": fb_trim,
661
860
  "error": f"quality_gate_failed:{reason}",
662
861
  "raw_model_output": raw[:500],
663
862
  "elapsed_sec": elapsed,
664
- }
665
- print(json.dumps(out), flush=True)
863
+ })
666
864
  return 0
667
865
 
668
866
  # Successful path: append the model-written bridge to the UNMODIFIED
@@ -674,12 +872,12 @@ def main() -> int:
674
872
  if limit is not None:
675
873
  # Belt-and-suspenders: should be a no-op given the bridge_budget
676
874
  # precheck above, unless the model ignored the length rule. Trims
677
- # from the END of the pre-URL text (the model's own bridge, appended
875
+ # from the END of the pre-CTA text (the model's own bridge, appended
678
876
  # last), so a well-behaved reply_text is only touched in the
679
877
  # degenerate case where reply_text alone already exceeds the limit.
680
- final_text, budget_trimmed = enforce_budget(final_text, link_url, limit)
878
+ final_text, budget_trimmed = _enforce(final_text, limit)
681
879
 
682
- out = {
880
+ _emit({
683
881
  "ok": True,
684
882
  "text": final_text,
685
883
  "tail": bridge,
@@ -687,8 +885,7 @@ def main() -> int:
687
885
  "fallback_used": False,
688
886
  "budget_trimmed": budget_trimmed,
689
887
  "elapsed_sec": elapsed,
690
- }
691
- print(json.dumps(out), flush=True)
888
+ })
692
889
  return 0
693
890
 
694
891
 
@@ -386,20 +386,18 @@ console.log(' manifest tools ('+tools.length+'): '+tools.map(t=>t.name).join(',
386
386
  # dependency on Desktop's internal behavior entirely: manifest.json's command now
387
387
  # points at this vendored binary's absolute path, so every session type — main
388
388
  # chat, agent-mode, any future session type — spawns the SAME real binary.
389
- # macOS arm64 only for now (matches every deployed box); revisit if x64 support
390
- # is ever needed.
389
+ # Both macOS arches since 1.7.9: arm64 (every deployed box) AND x64 (Intel Macs
390
+ # still on Tahoe). mcp/launch.js picks the arch-matched binary at spawn time.
391
391
  NODE_RUNTIME_VERSION="v24.18.0"
392
- NODE_RUNTIME_PLATFORM="darwin-arm64"
393
- NODE_VENDOR_DIR="$MCP_DIR/vendor/node-$NODE_RUNTIME_PLATFORM"
394
- NODE_VENDOR_BIN="$NODE_VENDOR_DIR/bin/node"
392
+ NODE_RUNTIME_PLATFORMS="darwin-arm64 darwin-x64"
395
393
  NODE_CACHE_DIR="$REPO_ROOT/.cache/node-runtime"
396
- # Can THIS build host execute the vendored target binary? The .mcpb always
397
- # vendors darwin-arm64 (that is where it runs), but the release itself is cut
398
- # from CI on linux-x64. Executing a darwin-arm64 node on a linux-x64 runner is
399
- # an `Exec format error` (exit 126) -- what broke every rc from 1.7.6-rc.11 on.
400
- # Run the exec-based `--version` sanity checks ONLY when the host os/arch matches
401
- # the vendored target; otherwise verify by presence + executable bit, which is
402
- # all a cross-build can honestly assert.
394
+ # Can THIS build host execute a given vendored binary? The release may be cut
395
+ # from CI on linux-x64, or from an arm64 Mac that cannot exec the x64 node
396
+ # natively (Rosetta usually covers it, but do not depend on that). Executing a
397
+ # foreign-arch node is an `Exec format error` (exit 126) -- what broke every rc
398
+ # from 1.7.6-rc.11 on. Run the exec-based `--version` sanity checks ONLY when
399
+ # the host os/arch matches the vendored target; otherwise verify by presence +
400
+ # executable bit, which is all a cross-build can honestly assert.
403
401
  case "$(uname -s)" in
404
402
  Darwin) HOST_OS="darwin" ;;
405
403
  Linux) HOST_OS="linux" ;;
@@ -410,40 +408,40 @@ case "$(uname -m)" in
410
408
  x86_64|amd64) HOST_ARCH="x64" ;;
411
409
  *) HOST_ARCH="unknown" ;;
412
410
  esac
413
- if [[ "$HOST_OS-$HOST_ARCH" == "$NODE_RUNTIME_PLATFORM" ]]; then
414
- HOST_CAN_EXEC_VENDORED_NODE=1
415
- else
416
- HOST_CAN_EXEC_VENDORED_NODE=0
417
- fi
418
- say "Vendoring Node $NODE_RUNTIME_VERSION ($NODE_RUNTIME_PLATFORM) for the .mcpb"
419
- if [[ ! -x "$NODE_VENDOR_BIN" ]]; then
420
- NODE_TARBALL="node-$NODE_RUNTIME_VERSION-$NODE_RUNTIME_PLATFORM.tar.gz"
421
- NODE_CACHED_TARBALL="$NODE_CACHE_DIR/$NODE_TARBALL"
422
- mkdir -p "$NODE_CACHE_DIR"
423
- if [[ ! -f "$NODE_CACHED_TARBALL" ]]; then
424
- say " downloading $NODE_TARBALL (not cached)"
425
- curl -sL "https://nodejs.org/dist/$NODE_RUNTIME_VERSION/$NODE_TARBALL" -o "$NODE_CACHED_TARBALL.tmp" \
426
- || die "Node download failed"
427
- mv "$NODE_CACHED_TARBALL.tmp" "$NODE_CACHED_TARBALL"
411
+ host_can_exec() { [[ "$HOST_OS-$HOST_ARCH" == "$1" ]]; }
412
+ for NODE_RUNTIME_PLATFORM in $NODE_RUNTIME_PLATFORMS; do
413
+ NODE_VENDOR_DIR="$MCP_DIR/vendor/node-$NODE_RUNTIME_PLATFORM"
414
+ NODE_VENDOR_BIN="$NODE_VENDOR_DIR/bin/node"
415
+ say "Vendoring Node $NODE_RUNTIME_VERSION ($NODE_RUNTIME_PLATFORM) for the .mcpb"
416
+ if [[ ! -x "$NODE_VENDOR_BIN" ]]; then
417
+ NODE_TARBALL="node-$NODE_RUNTIME_VERSION-$NODE_RUNTIME_PLATFORM.tar.gz"
418
+ NODE_CACHED_TARBALL="$NODE_CACHE_DIR/$NODE_TARBALL"
419
+ mkdir -p "$NODE_CACHE_DIR"
420
+ if [[ ! -f "$NODE_CACHED_TARBALL" ]]; then
421
+ say " downloading $NODE_TARBALL (not cached)"
422
+ curl -sL "https://nodejs.org/dist/$NODE_RUNTIME_VERSION/$NODE_TARBALL" -o "$NODE_CACHED_TARBALL.tmp" \
423
+ || die "Node download failed"
424
+ mv "$NODE_CACHED_TARBALL.tmp" "$NODE_CACHED_TARBALL"
425
+ else
426
+ say " using cached $NODE_CACHED_TARBALL"
427
+ fi
428
+ rm -rf "$NODE_VENDOR_DIR"
429
+ mkdir -p "$NODE_VENDOR_DIR/bin"
430
+ # Extract ONLY the node binary itself (no npm/npx/corepack — the server is
431
+ # invoked directly as `node dist/index.js`, nothing here ever needs npm).
432
+ tar -xzf "$NODE_CACHED_TARBALL" -O "node-$NODE_RUNTIME_VERSION-$NODE_RUNTIME_PLATFORM/bin/node" > "$NODE_VENDOR_BIN" \
433
+ || die "failed to extract node binary from $NODE_CACHED_TARBALL"
434
+ chmod +x "$NODE_VENDOR_BIN"
435
+ fi
436
+ [[ -x "$NODE_VENDOR_BIN" ]] || die "vendored node binary missing/not executable at $NODE_VENDOR_BIN"
437
+ if host_can_exec "$NODE_RUNTIME_PLATFORM"; then
438
+ VENDORED_NODE_VER="$("$NODE_VENDOR_BIN" --version)"
439
+ [[ "$VENDORED_NODE_VER" == "$NODE_RUNTIME_VERSION" ]] || die "vendored node reports $VENDORED_NODE_VER, expected $NODE_RUNTIME_VERSION"
440
+ echo " vendored node: $VENDORED_NODE_VER at mcp/vendor/node-$NODE_RUNTIME_PLATFORM/bin/node ($(du -h "$NODE_VENDOR_BIN" | cut -f1))"
428
441
  else
429
- say " using cached $NODE_CACHED_TARBALL"
442
+ echo " vendored node: present at mcp/vendor/node-$NODE_RUNTIME_PLATFORM/bin/node ($(du -h "$NODE_VENDOR_BIN" | cut -f1)); --version self-check skipped (host $HOST_OS-$HOST_ARCH cannot exec $NODE_RUNTIME_PLATFORM)"
430
443
  fi
431
- rm -rf "$NODE_VENDOR_DIR"
432
- mkdir -p "$NODE_VENDOR_DIR/bin"
433
- # Extract ONLY the node binary itself (no npm/npx/corepack — the server is
434
- # invoked directly as `node dist/index.js`, nothing here ever needs npm).
435
- tar -xzf "$NODE_CACHED_TARBALL" -O "node-$NODE_RUNTIME_VERSION-$NODE_RUNTIME_PLATFORM/bin/node" > "$NODE_VENDOR_BIN" \
436
- || die "failed to extract node binary from $NODE_CACHED_TARBALL"
437
- chmod +x "$NODE_VENDOR_BIN"
438
- fi
439
- [[ -x "$NODE_VENDOR_BIN" ]] || die "vendored node binary missing/not executable at $NODE_VENDOR_BIN"
440
- if [[ "$HOST_CAN_EXEC_VENDORED_NODE" == "1" ]]; then
441
- VENDORED_NODE_VER="$("$NODE_VENDOR_BIN" --version)"
442
- [[ "$VENDORED_NODE_VER" == "$NODE_RUNTIME_VERSION" ]] || die "vendored node reports $VENDORED_NODE_VER, expected $NODE_RUNTIME_VERSION"
443
- echo " vendored node: $VENDORED_NODE_VER at mcp/vendor/node-$NODE_RUNTIME_PLATFORM/bin/node ($(du -h "$NODE_VENDOR_BIN" | cut -f1))"
444
- else
445
- echo " vendored node: present at mcp/vendor/node-$NODE_RUNTIME_PLATFORM/bin/node ($(du -h "$NODE_VENDOR_BIN" | cut -f1)); --version self-check skipped (host $HOST_OS-$HOST_ARCH cannot exec $NODE_RUNTIME_PLATFORM)"
446
- fi
444
+ done
447
445
 
448
446
  # ---- 4. Pack the .mcpb ------------------------------------------------------
449
447
  say "Packing $BUNDLE"
@@ -493,31 +491,55 @@ for sub in mcp/dist/version.json mcp/package.json; do
493
491
  echo " pipeline.tgz $sub: $SUBV ok"
494
492
  done
495
493
 
496
- for f in "dist/index.js" "dist/runtime.js" "manifest.json" "vendor/node-$NODE_RUNTIME_PLATFORM/bin/node"; do
494
+ REQUIRED_FILES="dist/index.js dist/runtime.js manifest.json launch.js"
495
+ for p in $NODE_RUNTIME_PLATFORMS; do
496
+ REQUIRED_FILES="$REQUIRED_FILES vendor/node-$p/bin/node"
497
+ done
498
+ for f in $REQUIRED_FILES; do
497
499
  # grep -c reads all input (no SIGPIPE); anchor on the time column + 3-space
498
500
  # gutter so node_modules/.../dist/index.js does not false-match the top-level.
499
501
  n=$(printf '%s\n' "$LISTING" | grep -c "[0-9:] $f\$" || true)
500
502
  [[ "$n" -ge 1 ]] || die "bundle missing $f"
501
503
  done
502
- echo " runtime + server + manifest + vendored node: ok"
504
+ echo " runtime + server + manifest + launcher + vendored node (both arches): ok"
505
+
506
+ # The compat gate inside Claude Desktop only recognizes a node-type extension
507
+ # when mcp_config.command is the bare string "node" or a path ending in .js
508
+ # (its fo() command-recognizer). Pointing at the vendored node BINARY shipped
509
+ # "this extension is incompatible" to every Mac without a system Node.js
510
+ # (first hit: new-user install, 2026-08-10). Guard the manifest shape so a
511
+ # future edit cannot silently regress it.
512
+ MANIFEST_CMD=$(unzip -p "$BUNDLE" manifest.json 2>/dev/null | node -p "JSON.parse(require('fs').readFileSync(0,'utf8')).server.mcp_config.command" 2>/dev/null || echo "?")
513
+ case "$MANIFEST_CMD" in
514
+ node|node.exe|*.js) echo " manifest command Desktop-recognizable ($MANIFEST_CMD): ok" ;;
515
+ *) die "manifest mcp_config.command '$MANIFEST_CMD' is not recognizable by Claude Desktop's node compat gate (must be 'node' or end in .js); Macs without system Node.js will see 'incompatible' at install" ;;
516
+ esac
503
517
 
504
- # The vendored binary must survive the zip round-trip with its executable bit
505
- # intact (unzip -x extracts to a temp file and runs --version; a permissions
506
- # regression here would silently produce a Node that Desktop can spawn but that
507
- # refuses to execute, or vice versa).
518
+ # The vendored binaries and the launcher must survive the zip round-trip with
519
+ # their executable bits intact (extract to a temp dir and run --version where
520
+ # the host arch allows; a permissions regression here would silently produce a
521
+ # Node that Desktop can spawn but that refuses to execute, or a launch.js that
522
+ # agent-mode cannot exec).
508
523
  VENDOR_CHECK_DIR="$(mktemp -d)"
509
- unzip -q -o "$BUNDLE" "vendor/node-$NODE_RUNTIME_PLATFORM/bin/node" -d "$VENDOR_CHECK_DIR"
510
- VENDOR_CHECK_BIN="$VENDOR_CHECK_DIR/vendor/node-$NODE_RUNTIME_PLATFORM/bin/node"
511
- [[ -x "$VENDOR_CHECK_BIN" ]] || die "vendored node in bundle lost its executable bit"
512
- if [[ "$HOST_CAN_EXEC_VENDORED_NODE" == "1" ]]; then
513
- VENDOR_CHECK_VER="$("$VENDOR_CHECK_BIN" --version)"
514
- [[ "$VENDOR_CHECK_VER" == "$NODE_RUNTIME_VERSION" ]] || die "vendored node in bundle reports $VENDOR_CHECK_VER, expected $NODE_RUNTIME_VERSION"
515
- rm -rf "$VENDOR_CHECK_DIR"
516
- echo " vendored node runs from bundle: $VENDOR_CHECK_VER ok"
517
- else
518
- rm -rf "$VENDOR_CHECK_DIR"
519
- echo " vendored node survives bundle round-trip with exec bit intact; --version run skipped (host $HOST_OS-$HOST_ARCH cannot exec $NODE_RUNTIME_PLATFORM)"
520
- fi
524
+ for p in $NODE_RUNTIME_PLATFORMS; do
525
+ unzip -q -o "$BUNDLE" "vendor/node-$p/bin/node" -d "$VENDOR_CHECK_DIR"
526
+ VENDOR_CHECK_BIN="$VENDOR_CHECK_DIR/vendor/node-$p/bin/node"
527
+ [[ -x "$VENDOR_CHECK_BIN" ]] || die "vendored node ($p) in bundle lost its executable bit"
528
+ if host_can_exec "$p"; then
529
+ VENDOR_CHECK_VER="$("$VENDOR_CHECK_BIN" --version)"
530
+ [[ "$VENDOR_CHECK_VER" == "$NODE_RUNTIME_VERSION" ]] || die "vendored node ($p) in bundle reports $VENDOR_CHECK_VER, expected $NODE_RUNTIME_VERSION"
531
+ echo " vendored node ($p) runs from bundle: $VENDOR_CHECK_VER ok"
532
+ else
533
+ echo " vendored node ($p) survives bundle round-trip with exec bit intact; --version run skipped (host $HOST_OS-$HOST_ARCH cannot exec $p)"
534
+ fi
535
+ done
536
+ unzip -q -o "$BUNDLE" "launch.js" -d "$VENDOR_CHECK_DIR"
537
+ LAUNCH_CHECK="$VENDOR_CHECK_DIR/launch.js"
538
+ [[ -x "$LAUNCH_CHECK" ]] || die "launch.js in bundle lost its executable bit (agent-mode literal spawn would fail)"
539
+ head -c 9 "$LAUNCH_CHECK" | grep -q '^#!/bin/sh' || die "launch.js in bundle is missing its #!/bin/sh shebang"
540
+ node --check "$LAUNCH_CHECK" || die "launch.js in bundle is not valid JavaScript (Desktop built-in-node spawn would fail)"
541
+ rm -rf "$VENDOR_CHECK_DIR"
542
+ echo " launch.js: exec bit + shebang + JS syntax ok"
521
543
 
522
544
  if [[ "$DO_RELEASE" == "0" ]]; then
523
545
  say "Done (--no-release). Bundle ready at: $BUNDLE"
@@ -143,21 +143,64 @@ def parse_last_json_object(text):
143
143
 
144
144
 
145
145
  def _tail_link_rate() -> float:
146
+ # "Add a tail at all?" gate. Default 1.0 since 2026-08-08: the old
147
+ # link-vs-no_link A/B concluded that day (no_link won engagement but the
148
+ # link arm bought clicks), and its successor — twitter-tail-cta (link vs
149
+ # plain product name) — assumes EVERY promo post gets a tail, differing
150
+ # only in HOW it ends. So promo posts now always get a tail; the
151
+ # personal_brand lane still forces no tail via its own
152
+ # TWITTER_TAIL_LINK_RATE=0 export, and setting the var to 0.0 anywhere
153
+ # still disables tails entirely.
154
+ #
146
155
  # DRAFT_ONLY=1 candidates always go through a human-approval review card
147
156
  # (run-draft-and-publish.sh exports DRAFT_ONLY before invoking the cycle
148
157
  # that calls this script). Force rate=1.0 there so a hand-approved draft
149
- # never drops the link the user already saw baked into the card text —
158
+ # never drops the tail the user already saw baked into the card text —
150
159
  # ported from the old post-time override in mcp/src/index.ts
151
160
  # (`TWITTER_TAIL_LINK_RATE: "1.0"` on the approve_drafts path), which no
152
161
  # longer fires now that the decision happens here, before the card is
153
- # ever shown. The autonomous DRAFT_ONLY=0 lane keeps running the real A/B
154
- # experiment at the configured rate.
162
+ # ever shown.
155
163
  if os.environ.get("DRAFT_ONLY") == "1":
156
164
  return 1.0
157
165
  try:
158
- return float(os.environ.get("TWITTER_TAIL_LINK_RATE", "0.5"))
166
+ return float(os.environ.get("TWITTER_TAIL_LINK_RATE", "1.0"))
167
+ except ValueError:
168
+ return 1.0
169
+
170
+
171
+ def _cta_link_share() -> float:
172
+ """P(link arm | a tail is added) for the twitter-tail-cta experiment
173
+ (link vs plain product name), launched 2026-08-08. The complementary share
174
+ goes to the 'name' arm. Read live from TWITTER_TAIL_CTA_LINK_SHARE so the
175
+ split can be swept without a code change; defaults to 0.5."""
176
+ try:
177
+ v = float(os.environ.get("TWITTER_TAIL_CTA_LINK_SHARE", "0.5"))
159
178
  except ValueError:
160
179
  return 0.5
180
+ if v < 0.0:
181
+ return 0.0
182
+ if v > 1.0:
183
+ return 1.0
184
+ return v
185
+
186
+
187
+ def _resolve_product_name(project_name: str) -> str:
188
+ """Plain-text product name for the 'name' arm of twitter-tail-cta.
189
+ config.json entry's product_name, then its name, then a title-cased form
190
+ of the project slug (so we always have SOMETHING to plant). Mirrors
191
+ link_tail.resolve_product_name."""
192
+ try:
193
+ projects = load_projects()
194
+ except Exception:
195
+ projects = {}
196
+ entry = projects.get(project_name) or {}
197
+ pn = (entry.get("product_name") or "").strip()
198
+ if pn:
199
+ return pn
200
+ nm = (entry.get("name") or "").strip()
201
+ if nm:
202
+ return nm
203
+ return (project_name or "").replace("_", " ").replace("-", " ").strip().title()
161
204
 
162
205
 
163
206
  def apply_tail_link(candidate: dict, link_url: str) -> None:
@@ -181,39 +224,72 @@ def apply_tail_link(candidate: dict, link_url: str) -> None:
181
224
  twitter_post_plan.py can detect this candidate is already finalized and
182
225
  skip its own (now fallback-only) tail-link block.
183
226
 
227
+ 2026-08-08 — twitter-tail-cta experiment. The old link-vs-no_link A/B
228
+ concluded; _tail_link_rate() now defaults to 1.0 (a tail is essentially
229
+ always added for promo posts). Once a tail is added, the arm is a coin
230
+ flip between BOTH tails carrying the AI-written bridge:
231
+ - 'link': the bridge ends with the tracked URL (historical behavior).
232
+ - 'name': the bridge ends with the PLAIN product name, no URL — passed
233
+ via --cta-mode name and NEVER appending link_url.
234
+ A rate below 1.0 (or personal_brand's TWITTER_TAIL_LINK_RATE=0) still
235
+ yields the 'no_link' arm, which stamps but doesn't feed the tail-cta card.
236
+
184
237
  Idempotent: a candidate that already carries tail_link_variant is a
185
238
  no-op. Guards against any re-run of this stage over the same candidate
186
239
  (retry, salvage, plan re-merge) re-rolling the decision and re-appending
187
- link_url onto a reply_text that already has it baked in.
240
+ the tail onto a reply_text that already has it baked in.
188
241
  """
189
242
  if not link_url or candidate.get("tail_link_variant"):
190
243
  return
191
244
  rate = _tail_link_rate()
192
- add_link = random.random() < rate
193
- candidate["tail_link_variant"] = "link" if add_link else "no_link"
194
- if not add_link:
245
+ if random.random() >= rate:
246
+ # Tail explicitly disabled (rate < 1.0 / personal_brand). Stamp the
247
+ # no_link arm; it does not feed the twitter-tail-cta card.
248
+ candidate["tail_link_variant"] = "no_link"
195
249
  print(f"[gen] candidate_id={candidate.get('candidate_id')} "
196
250
  f"tail_link: ab_no_link (rate={rate})", flush=True)
197
251
  return
198
252
  reply_text = (candidate.get("reply_text") or "").strip()
199
- cmd = [
200
- "python3", LINK_TAIL,
201
- "--reply-text", reply_text,
202
- "--link-url", link_url,
203
- "--thread-text", candidate.get("thread_text") or "",
204
- "--project", candidate.get("matched_project") or "",
205
- "--platform", "twitter",
206
- "--timeout", str(LINK_TAIL_TIMEOUT_SEC),
207
- ]
253
+ project = candidate.get("matched_project") or ""
254
+ use_link = random.random() < _cta_link_share()
255
+ candidate["tail_link_variant"] = "link" if use_link else "name"
256
+ if use_link:
257
+ product_name = ""
258
+ mech = f"{reply_text} {link_url}".strip()
259
+ cmd = [
260
+ "python3", LINK_TAIL,
261
+ "--reply-text", reply_text,
262
+ "--link-url", link_url,
263
+ "--thread-text", candidate.get("thread_text") or "",
264
+ "--project", project,
265
+ "--platform", "twitter",
266
+ "--cta-mode", "url",
267
+ "--timeout", str(LINK_TAIL_TIMEOUT_SEC),
268
+ ]
269
+ else:
270
+ # Name arm: end with the plain product name, NO URL in the reply.
271
+ product_name = _resolve_product_name(project)
272
+ mech = f"{reply_text} {product_name}".strip()
273
+ cmd = [
274
+ "python3", LINK_TAIL,
275
+ "--reply-text", reply_text,
276
+ "--product-name", product_name,
277
+ "--thread-text", candidate.get("thread_text") or "",
278
+ "--project", project,
279
+ "--platform", "twitter",
280
+ "--cta-mode", "name",
281
+ "--timeout", str(LINK_TAIL_TIMEOUT_SEC),
282
+ ]
208
283
  try:
209
284
  r = subprocess.run(cmd, capture_output=True, text=True,
210
285
  timeout=LINK_TAIL_TIMEOUT_SEC + 30)
211
286
  rc, out = r.returncode, r.stdout
212
287
  except subprocess.TimeoutExpired:
213
- candidate["reply_text"] = f"{reply_text} {link_url}".strip()
288
+ candidate["reply_text"] = mech
214
289
  candidate["link_tail_outcome"] = "hard_timeout"
215
290
  print(f"[gen] candidate_id={candidate.get('candidate_id')} "
216
- f"link_tail: hard_timeout; keeping mechanical concat", flush=True)
291
+ f"link_tail[{candidate['tail_link_variant']}]: hard_timeout; "
292
+ f"keeping mechanical concat", flush=True)
217
293
  return
218
294
  obj = parse_last_json_object(out) or {}
219
295
  if obj.get("text"):
@@ -225,12 +301,13 @@ def apply_tail_link(candidate: dict, link_url: str) -> None:
225
301
  else:
226
302
  # link_tail.py is supposed to ALWAYS return JSON; if we got nothing,
227
303
  # hard-fall-back to the mechanical concat so the card still shows a
228
- # complete draft (link still present) instead of a bare reply.
229
- candidate["reply_text"] = f"{reply_text} {link_url}".strip()
304
+ # complete draft (tail still present) instead of a bare reply.
305
+ candidate["reply_text"] = mech
230
306
  outcome = f"hard_fallback_no_json:rc={rc}"
231
307
  candidate["link_tail_outcome"] = outcome
232
308
  print(f"[gen] candidate_id={candidate.get('candidate_id')} "
233
- f"link_tail: {outcome} (elapsed={obj.get('elapsed_sec')}s)", flush=True)
309
+ f"link_tail[{candidate['tail_link_variant']}]: {outcome} "
310
+ f"(elapsed={obj.get('elapsed_sec')}s)", flush=True)
234
311
 
235
312
 
236
313
  def run_generate(product: str, keyword: str, slug: str) -> tuple[str, str]:
@@ -119,6 +119,29 @@ LOG_POST = os.path.join(REPO_DIR, "scripts", "log_post.py")
119
119
  CAMPAIGN_BUMP = os.path.join(REPO_DIR, "scripts", "campaign_bump.py")
120
120
  LINK_TAIL = os.path.join(REPO_DIR, "scripts", "link_tail.py")
121
121
 
122
+
123
+ def _resolve_product_name(project: str) -> str:
124
+ """Plain-text product name for the 'name' arm of the twitter-tail-cta
125
+ fallback below (mirrors twitter_gen_links._resolve_product_name /
126
+ link_tail.resolve_product_name): config.json entry's product_name, then
127
+ its name, then a title-cased form of the project slug."""
128
+ try:
129
+ with open(os.path.join(REPO_DIR, "config.json")) as f:
130
+ cfg = json.load(f)
131
+ except Exception:
132
+ cfg = {}
133
+ name_lc = (project or "").lower()
134
+ for p in cfg.get("projects", []):
135
+ if (p.get("name") or "").lower() == name_lc:
136
+ pn = (p.get("product_name") or "").strip()
137
+ if pn:
138
+ return pn
139
+ nm = (p.get("name") or "").strip()
140
+ if nm:
141
+ return nm
142
+ break
143
+ return (project or "").replace("_", " ").replace("-", " ").strip().title()
144
+
122
145
  # Interpreter every child subprocess (twitter_browser.py reply, log_post.py,
123
146
  # campaign_bump.py, link_tail.py) must run under. The reply path is the only
124
147
  # Playwright importer in the pipeline, so a bare "python3" here silently
@@ -632,28 +655,60 @@ def post_one(c: dict, picker_assignment: dict | None = None) -> tuple[str, str]:
632
655
  f"(already finalized at draft time, tail_link_variant={tail_link_variant})",
633
656
  flush=True)
634
657
  else:
635
- # AB TEST tail link on/off:
636
- # TWITTER_TAIL_LINK_RATE (float 0..1, default 0.5) controls the fraction
637
- # of posts that receive a tail link. Setting it to 1.0 restores old
638
- # behavior (always add link). Setting it to 0.0 disables links entirely.
639
- # tail_link_variant is logged to posts.tail_link_variant so the dashboard
640
- # can compare engagement across arms.
641
- _tail_link_rate = float(os.environ.get("TWITTER_TAIL_LINK_RATE", "0.5"))
642
- _add_tail_link = link_url and (random.random() < _tail_link_rate)
643
- if link_url:
644
- tail_link_variant = "link" if _add_tail_link else "no_link"
658
+ # FALLBACK ONLY (candidate reached post time unstamped). Mirrors
659
+ # twitter_gen_links.apply_tail_link. AB TEST twitter-tail-cta
660
+ # (link vs plain product name), launched 2026-08-08. The prior
661
+ # link-vs-no_link A/B concluded that day (no_link won engagement but the
662
+ # link arm bought clicks); both arms now append the AI-written bridge,
663
+ # differing only in whether it ends with the tracked URL ('link') or the
664
+ # plain product name ('name', no URL).
665
+ # TWITTER_TAIL_LINK_RATE (float 0..1, default 1.0) still gates whether
666
+ # ANY tail is added (0.0 disables tails; personal_brand exports 0);
667
+ # TWITTER_TAIL_CTA_LINK_SHARE (float 0..1, default 0.5) splits link vs
668
+ # name once a tail is added. tail_link_variant is logged to
669
+ # posts.tail_link_variant so the dashboard can compare arms.
670
+ try:
671
+ _tail_link_rate = float(os.environ.get("TWITTER_TAIL_LINK_RATE", "1.0"))
672
+ except ValueError:
673
+ _tail_link_rate = 1.0
674
+ _add_tail = link_url and (random.random() < _tail_link_rate)
645
675
  link_tail_outcome = "skipped_no_link"
646
- if _add_tail_link:
647
- rc, out, err = run_subprocess(
648
- [PYTHON, LINK_TAIL,
649
- "--reply-text", reply_text,
650
- "--link-url", link_url,
651
- "--thread-text", thread_text or "",
652
- "--project", project,
653
- "--platform", "twitter",
654
- "--timeout", "120"],
655
- timeout_sec=180,
656
- )
676
+ if not _add_tail:
677
+ if link_url:
678
+ # Tail disabled: post the reply as-is (no bridge, no URL).
679
+ tail_link_variant = "no_link"
680
+ link_tail_outcome = "ab_no_link"
681
+ print(f"[post] candidate {cid} link_tail: {link_tail_outcome} "
682
+ f"(tail_link_variant=no_link, rate={_tail_link_rate})", flush=True)
683
+ else:
684
+ try:
685
+ _cta_link_share = float(os.environ.get("TWITTER_TAIL_CTA_LINK_SHARE", "0.5"))
686
+ except ValueError:
687
+ _cta_link_share = 0.5
688
+ _use_link = random.random() < _cta_link_share
689
+ tail_link_variant = "link" if _use_link else "name"
690
+ if _use_link:
691
+ _tail_cmd = [PYTHON, LINK_TAIL,
692
+ "--reply-text", reply_text,
693
+ "--link-url", link_url,
694
+ "--thread-text", thread_text or "",
695
+ "--project", project,
696
+ "--platform", "twitter",
697
+ "--cta-mode", "url",
698
+ "--timeout", "120"]
699
+ _mech = f"{reply_text} {link_url}".strip()
700
+ else:
701
+ _product_name = _resolve_product_name(project)
702
+ _tail_cmd = [PYTHON, LINK_TAIL,
703
+ "--reply-text", reply_text,
704
+ "--product-name", _product_name,
705
+ "--thread-text", thread_text or "",
706
+ "--project", project,
707
+ "--platform", "twitter",
708
+ "--cta-mode", "name",
709
+ "--timeout", "120"]
710
+ _mech = f"{reply_text} {_product_name}".strip()
711
+ rc, out, err = run_subprocess(_tail_cmd, timeout_sec=180)
657
712
  tail_obj = parse_last_json_object(out) or {}
658
713
  if tail_obj.get("text"):
659
714
  full_text = tail_obj["text"]
@@ -664,17 +719,11 @@ def post_one(c: dict, picker_assignment: dict | None = None) -> tuple[str, str]:
664
719
  else:
665
720
  # link_tail.py is supposed to ALWAYS return JSON; if we got
666
721
  # nothing, hard-fall-back to the mechanical concat to preserve
667
- # prior behavior (post still ships, link still on the wire).
668
- full_text = f"{reply_text} {link_url}".strip()
722
+ # prior behavior (post still ships, tail still on the wire).
723
+ full_text = _mech
669
724
  link_tail_outcome = f"hard_fallback_no_json:rc={rc}"
670
- print(f"[post] candidate {cid} link_tail: {link_tail_outcome} "
671
- f"(elapsed={tail_obj.get('elapsed_sec')}s)", flush=True)
672
- elif link_url and not _add_tail_link:
673
- # No-link arm of the AB test: post the reply text as-is (no CTA bridge,
674
- # no URL). Log the outcome so the dashboard can tally the arm.
675
- link_tail_outcome = "ab_no_link"
676
- print(f"[post] candidate {cid} link_tail: {link_tail_outcome} "
677
- f"(tail_link_variant=no_link, rate={_tail_link_rate})", flush=True)
725
+ print(f"[post] candidate {cid} link_tail[{tail_link_variant}]: "
726
+ f"{link_tail_outcome} (elapsed={tail_obj.get('elapsed_sec')}s)", flush=True)
678
727
 
679
728
  # URL-wrap the text BEFORE handing it to twitter_browser. The browser
680
729
  # script appends the campaign suffix internally; suffixes are plain