@m13v/s4l 1.7.2 → 1.7.4-rc.1

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.
@@ -0,0 +1,245 @@
1
+ #!/usr/bin/env python3
2
+ """scripts/invent_styles.py — standalone daily engagement-style invention.
3
+
4
+ Architectural split (2026-07-10, mirrors the 2026-05-28 invent_topics.py
5
+ split): in-cycle style invention (the picker's 5% INVENT_RATE roll) is
6
+ retired. It ran INSIDE the drafting prep session, with the top-performers
7
+ leaderboard and winner exemplars in context, so every "new" style was a
8
+ renamed variant of the currently-winning move (agree-then-relocate /
9
+ concede-then-reverse). Combined with name-only dedup in register_style,
10
+ the registry accumulated hundreds of semantic clones.
11
+
12
+ This job runs OUTSIDE any drafting context, on the operator Mac only:
13
+
14
+ - The registry is GLOBAL across installs (every install reads every
15
+ style), so invention must NOT fan out per install the way topic
16
+ invention does. One central daily run is the correct scope; that is
17
+ the deliberate difference from invent_topics.py's per-install kicker.
18
+ - The prompt carries the existing style universe and explicitly forbids
19
+ the dominant structural family; the ask is a style from an
20
+ UNREPRESENTED family, not a riff on the champion.
21
+ - Post-hoc semantic dedup (token-Jaccard on description+example, plus a
22
+ reframe-family heuristic) rejects near-clones; a rejection re-prompts
23
+ with a grown avoid-list, at most DUPE_RETRIES times.
24
+ - Accepted styles are registered via engagement_styles.register_style
25
+ (kind='model_invented'), same as the retired inline path, so pickers
26
+ see them on their next tick with zero other wiring.
27
+
28
+ Scheduling: launchd com.m13v.s4l-invent-styles (operator Mac, daily).
29
+ Uses run_claude.sh with tag 'invent-styles'; the tag is NOT in
30
+ claude_job.py TAG_TO_TYPE, so it runs the local `claude -p` lane and has
31
+ no dependency on the Desktop queue worker.
32
+
33
+ CLI:
34
+ python3 scripts/invent_styles.py # invent + register 1
35
+ python3 scripts/invent_styles.py --max-new 2
36
+ python3 scripts/invent_styles.py --dry-run # propose, don't register
37
+ """
38
+
39
+ import argparse
40
+ import json
41
+ import os
42
+ import re
43
+ import subprocess
44
+ import sys
45
+
46
+ _REPO_DIR = os.path.expanduser("~/social-autoposter")
47
+ sys.path.insert(0, os.path.join(_REPO_DIR, "scripts"))
48
+ from engagement_styles import get_all_styles, register_style # noqa: E402
49
+
50
+ _RUN_CLAUDE_SH = os.path.join(_REPO_DIR, "scripts", "run_claude.sh")
51
+ SCRIPT_TAG = "invent-styles"
52
+ CALL_TIMEOUT_SEC = 420
53
+ DUPE_RETRIES = 3
54
+ SIMILARITY_THRESHOLD = 0.5 # Jaccard on description+example tokens
55
+
56
+ # Heuristic markers of the saturated agree-then-relocate/reframe family.
57
+ # A proposal whose description/example leans on these is a clone of the
58
+ # dominant move no matter how novel its name is; reject like a dupe.
59
+ _REFRAME_MARKERS = re.compile(
60
+ r"\b(reframe|relocat\w+|the real (work|question|problem|cost|part|win)"
61
+ r"|hidden (cost|part|work|meter)|easy (part|half)|hard(er)? (part|half)"
62
+ r"|was never (the|about)|nobody (mentions|talks about|tracks|measures)"
63
+ r"|isn.t the .{0,30}it.s|concede)\b",
64
+ re.I,
65
+ )
66
+
67
+
68
+ def _tokens(text):
69
+ return set(re.findall(r"[a-z0-9]+", (text or "").lower()))
70
+
71
+
72
+ def _jaccard(a, b):
73
+ ta, tb = _tokens(a), _tokens(b)
74
+ if not ta or not tb:
75
+ return 0.0
76
+ return len(ta & tb) / len(ta | tb)
77
+
78
+
79
+ def _style_text(entry):
80
+ return f"{entry.get('description') or ''} {entry.get('example') or ''}"
81
+
82
+
83
+ def _find_near_dupe(proposal, universe):
84
+ """Return (reason, existing_name) when the proposal is a clone; else None."""
85
+ name = proposal["name"]
86
+ if name in universe:
87
+ return ("name_exists", name)
88
+ ptext = _style_text(proposal)
89
+ if _REFRAME_MARKERS.search(f"{proposal.get('description','')} {name}"):
90
+ return ("reframe_family", "(dominant agree-then-relocate family)")
91
+ best_name, best_sim = None, 0.0
92
+ for ename, entry in universe.items():
93
+ sim = _jaccard(ptext, _style_text(entry))
94
+ if sim > best_sim:
95
+ best_name, best_sim = ename, sim
96
+ if best_sim >= SIMILARITY_THRESHOLD:
97
+ return (f"jaccard={best_sim:.2f}", best_name)
98
+ return None
99
+
100
+
101
+ def build_prompt(universe, avoid):
102
+ names = sorted(universe.keys())
103
+ # Detail only a bounded sample (prompt-size guard): the seeds plus the
104
+ # most recently registered rows carry enough signal about what already
105
+ # exists; the full NAME list covers the rest for the model's self-check.
106
+ detailed = []
107
+ for name in names:
108
+ e = universe[name]
109
+ if e.get("kind") == "seed" or len(detailed) < 40:
110
+ desc = " ".join((e.get("description") or "").split())[:180]
111
+ detailed.append(f"- {name}: {desc}")
112
+ if len(detailed) >= 60:
113
+ break
114
+ avoid_block = ""
115
+ if avoid:
116
+ avoid_block = (
117
+ "\nAlready proposed and REJECTED this run (do not resubmit or "
118
+ "paraphrase): " + ", ".join(avoid) + "\n"
119
+ )
120
+ return f"""You maintain the engagement-style registry for a social reply system. A style is a named rhetorical TEMPLATE (description + one example reply) that drafters follow when writing short replies on X/Twitter and Reddit.
121
+
122
+ PROBLEM: the registry is saturated with ONE structural family: agree-then-relocate (concede the surface point, move the spotlight to the hidden/harder/unmeasured part, often "X is easy, Y is the real work" or "X was never the point, Y is"). Do NOT invent another member of that family, however disguised.
123
+
124
+ EXISTING STYLE NAMES ({len(names)} total):
125
+ {", ".join(names)}
126
+
127
+ REPRESENTATIVE DETAILS (sample):
128
+ {chr(10).join(detailed)}
129
+ {avoid_block}
130
+ TASK: invent exactly ONE genuinely new style from a structural family that is missing or rare above. Families worth mining (pick ONE, or another you identify): direct answer with zero framing; first-person confession of a specific failure; pure curious question with no thesis; dry understatement one-liner; enthusiastic cosign with one concrete addition; flat disagreement stated plainly without conceding anything first; a tiny numbered checklist; a vivid analogy that does NOT end in a lesson; deadpan humor riffing on the thread's wording.
131
+
132
+ Rules:
133
+ - The example must read like a real human reply (lowercase ok), 40-220 chars, NO links, NO product names.
134
+ - The style must be usable across many products and threads, not thread-specific.
135
+ - Your answer will be dedup-checked by token similarity against every existing style's description+example; if you cannot find a genuinely different move, return the saturation envelope instead of forcing a paraphrase.
136
+
137
+ Answer with ONLY one JSON object, no prose, in one of these two shapes:
138
+ {{"name": "snake_case_name", "description": "...", "example": "...", "why_existing_didnt_fit": "...", "target_chars": <int, the length the example is>}}
139
+ {{"saturated": true, "reason": "..."}}"""
140
+
141
+
142
+ def call_claude(prompt):
143
+ proc = subprocess.run(
144
+ ["bash", _RUN_CLAUDE_SH, SCRIPT_TAG, "-p", "--output-format", "json",
145
+ prompt],
146
+ text=True, capture_output=True, timeout=CALL_TIMEOUT_SEC,
147
+ )
148
+ if proc.returncode == 79:
149
+ raise SystemExit("[invent_styles] provider blocked (exit 79); skipping run")
150
+ if proc.returncode != 0:
151
+ raise SystemExit(
152
+ f"[invent_styles] run_claude.sh exited {proc.returncode}: "
153
+ f"{(proc.stderr or '')[:400]}")
154
+ try:
155
+ envelope = json.loads(proc.stdout)
156
+ text = envelope.get("result") or ""
157
+ except json.JSONDecodeError:
158
+ text = proc.stdout
159
+ return text
160
+
161
+
162
+ def _extract_json(text):
163
+ """Pull the first parseable JSON object out of model output."""
164
+ text = (text or "").strip()
165
+ fence = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", text, re.S)
166
+ if fence:
167
+ text = fence.group(1)
168
+ start = text.find("{")
169
+ while start != -1:
170
+ for end in range(len(text), start, -1):
171
+ try:
172
+ obj = json.loads(text[start:end])
173
+ if isinstance(obj, dict):
174
+ return obj
175
+ except json.JSONDecodeError:
176
+ continue
177
+ start = text.find("{", start + 1)
178
+ return None
179
+
180
+
181
+ def main():
182
+ parser = argparse.ArgumentParser(description="Standalone style invention job")
183
+ parser.add_argument("--max-new", type=int, default=1,
184
+ help="How many styles to invent this run (default 1)")
185
+ parser.add_argument("--dry-run", action="store_true",
186
+ help="Propose and dedup-check but do not register")
187
+ args = parser.parse_args()
188
+
189
+ registered = 0
190
+ for slot in range(max(1, args.max_new)):
191
+ universe = get_all_styles()
192
+ avoid = []
193
+ accepted = None
194
+ for attempt in range(1 + DUPE_RETRIES):
195
+ raw = call_claude(build_prompt(universe, avoid))
196
+ obj = _extract_json(raw)
197
+ if not obj:
198
+ print(f"[invent_styles] slot={slot} attempt={attempt} "
199
+ f"unparseable output: {raw[:200]!r}", file=sys.stderr)
200
+ continue
201
+ if obj.get("saturated"):
202
+ print(f"[invent_styles] slot={slot} model reports saturation: "
203
+ f"{obj.get('reason', '')[:200]}", file=sys.stderr)
204
+ break
205
+ name = str(obj.get("name") or "").strip()
206
+ if not re.fullmatch(r"[a-z0-9_]{3,60}", name):
207
+ print(f"[invent_styles] slot={slot} bad name {name!r}; retrying",
208
+ file=sys.stderr)
209
+ avoid.append(name or "(unnamed)")
210
+ continue
211
+ dupe = _find_near_dupe({**obj, "name": name}, universe)
212
+ if dupe:
213
+ reason, existing = dupe
214
+ print(f"[invent_styles] slot={slot} rejected {name!r}: {reason} "
215
+ f"vs {existing}", file=sys.stderr)
216
+ avoid.append(name)
217
+ continue
218
+ accepted = {**obj, "name": name}
219
+ break
220
+ if not accepted:
221
+ continue
222
+ if args.dry_run:
223
+ print(json.dumps({"dry_run": True, **accepted}, indent=2))
224
+ continue
225
+ status, entry = register_style(
226
+ accepted["name"],
227
+ {
228
+ "description": accepted.get("description", ""),
229
+ "example": accepted.get("example", ""),
230
+ "why_existing_didnt_fit": accepted.get("why_existing_didnt_fit", ""),
231
+ "target_chars": accepted.get("target_chars"),
232
+ },
233
+ source_post={"platform": "invent_styles_job", "model": "invent_styles"},
234
+ )
235
+ print(f"[invent_styles] slot={slot} register status={status} "
236
+ f"name={accepted['name']}", file=sys.stderr)
237
+ if status == "new":
238
+ registered += 1
239
+ print(json.dumps({"registered": accepted["name"],
240
+ "description": accepted.get("description", "")}))
241
+ print(f"[invent_styles] done: registered={registered}", file=sys.stderr)
242
+
243
+
244
+ if __name__ == "__main__":
245
+ main()
@@ -19,6 +19,15 @@ Usage:
19
19
 
20
20
  State dir (for review-request.json) honors $S4L_STATE_DIR; the review-queue plan
21
21
  lives in $S4L_TMP_DIR or /tmp (matching the MCP's planPath()).
22
+
23
+ LEDGER SEMANTICS (read this before counting anything in review-queue.json):
24
+ the queue is APPEND-FOREVER — handled candidates are never removed, they are
25
+ flag-stamped in place (posted / terminal+terminal_reason / post_failed /
26
+ approved). "Not posted" is therefore NOT "awaiting review"; in an old queue
27
+ most rows are retired. The canonical classifier is
28
+ mcp/menubar/s4l_state.py::candidate_state(); the honest pending-cards count is
29
+ its awaiting_review bucket, mirrored into review-request.json's .count here at
30
+ merge time.
22
31
  """
23
32
 
24
33
  from __future__ import annotations
@@ -318,10 +327,21 @@ def main() -> int:
318
327
  _atomic_write(dst, plan_obj)
319
328
  ensure_store_symlink()
320
329
 
321
- # Refresh the review-request marker the menu bar polls (count = pending,
322
- # not posted, not terminal -- a just-pruned expired card must not still
323
- # inflate the badge).
324
- pending_count = len([c for c in merged if not c.get("posted") and not c.get("terminal")])
330
+ # Refresh the review-request marker the menu bar polls. count = cards
331
+ # actually awaiting review (mirrors s4l_state.candidate_state()'s
332
+ # awaiting_review bucket): approved-unposted and post_failed rows are
333
+ # settled decisions, counting them inflated the badge and misled every
334
+ # human/agent reading the marker.
335
+ pending_count = len(
336
+ [
337
+ c
338
+ for c in merged
339
+ if not c.get("posted")
340
+ and not c.get("terminal")
341
+ and not c.get("post_failed")
342
+ and not c.get("approved")
343
+ ]
344
+ )
325
345
  project = ns.project or batch.get("project") or (new_cands[0].get("matched_project") if new_cands else None)
326
346
  _atomic_write(
327
347
  review_request_path(),
@@ -74,10 +74,17 @@ def recent_posts_by_project(platform=None, days=RECENT_WINDOW_DAYS):
74
74
 
75
75
 
76
76
  def _eligible_pool(config, platform=None, exclude=None):
77
- """Projects eligible for selection: enabled, weight>0, platform-compatible."""
77
+ """Projects eligible for selection: enabled, weight>0, non-persona,
78
+ platform-compatible.
79
+
80
+ Persona entries (`persona: true`) are structurally excluded: they run ONLY
81
+ via the personal_brand lane's force-inject (s4l_mode.persona_name), never
82
+ via this promotion lottery, regardless of their `enabled` flag.
83
+ """
78
84
  pool = [
79
85
  p for p in config.get("projects", [])
80
86
  if p.get("enabled", True) and p.get("weight", 0) > 0
87
+ and p.get("persona") is not True
81
88
  ]
82
89
  if exclude:
83
90
  excluded = {n.lower() for n in exclude}
@@ -122,8 +129,10 @@ def pick_project(config, platform=None, exclude=None):
122
129
  return picks[0]
123
130
  if exclude:
124
131
  return None
125
- # No eligible project at all: legacy fallback to any project in config.
126
- projects = config.get("projects", [])
132
+ # No eligible project at all: legacy fallback to any non-persona project.
133
+ projects = [
134
+ p for p in config.get("projects", []) if p.get("persona") is not True
135
+ ]
127
136
  return random.choice(projects) if projects else None
128
137
 
129
138
 
@@ -0,0 +1,146 @@
1
+ #!/usr/bin/env python3
2
+ """scripts/recent_self_posts.py — cross-cycle anti-repetition context.
3
+
4
+ Prints a prompt block listing OUR most recent posted replies on a platform
5
+ (across ALL projects), so a drafting session can see what this account
6
+ already sounds like and deliberately diverge. This is the cross-cycle
7
+ complement to author_history_block.py (which is per-author only): before
8
+ 2026-07-10 the model never saw its own recent output across threads and
9
+ kept recycling the same openers and sentence skeletons cycle after cycle.
10
+
11
+ The block is explicitly NEGATIVE context ("do not sound like these"), the
12
+ opposite of the top_performers few-shots. Keep it that way: never add
13
+ engagement numbers or any "this one did well" framing here, or the model
14
+ will read it as examples to imitate.
15
+
16
+ Wired into (one callsite):
17
+ - skill/run-twitter-cycle.sh (Phase 2b-prep PREP_PROMPT)
18
+
19
+ CLI:
20
+ python3 scripts/recent_self_posts.py --platform twitter --limit 20
21
+
22
+ Stdout is a ready-to-inject prompt block; EMPTY stdout when there are no
23
+ rows or on any failure (the cycle must never block on this context).
24
+ Stderr carries diagnostics only.
25
+ """
26
+
27
+ import argparse
28
+ import os
29
+ import sys
30
+
31
+ REPO_DIR = os.path.expanduser("~/social-autoposter")
32
+ sys.path.insert(0, os.path.join(REPO_DIR, "scripts"))
33
+ from http_api import api_get # noqa: E402
34
+
35
+ # Truncation length per reply. Long enough to expose the opener + skeleton
36
+ # (the parts the model must avoid repeating), short enough that 20 rows stay
37
+ # a small fraction of the prompt.
38
+ SNIPPET_CHARS = 220
39
+
40
+
41
+ def _load_active_campaign_suffixes():
42
+ """Best-effort list of active campaign suffix literals to strip.
43
+
44
+ Same contract as author_history_block._load_active_campaign_suffixes:
45
+ the block must never teach the model to echo a campaign suffix (the
46
+ tool layer appends its own copy at post time). On failure returns [].
47
+ """
48
+ suffixes = []
49
+ try:
50
+ resp = api_get(
51
+ "/api/v1/campaigns",
52
+ query={"status": "active", "has_suffix": "true", "limit": 500},
53
+ )
54
+ rows = ((resp or {}).get("data") or {}).get("campaigns") or []
55
+ for r in rows:
56
+ s = (r.get("suffix") or "").strip()
57
+ if s and s not in suffixes:
58
+ suffixes.append(s)
59
+ except Exception as e:
60
+ print(f"[recent_self_posts] suffix load failed: {e!r}", file=sys.stderr)
61
+ return suffixes
62
+
63
+
64
+ def _strip_suffixes(text, suffixes):
65
+ """Trailing-only, idempotent strip (mirrors author_history_block)."""
66
+ if not text or not suffixes:
67
+ return text
68
+ cleaned = text.rstrip()
69
+ changed = True
70
+ while changed:
71
+ changed = False
72
+ for sfx in suffixes:
73
+ if sfx and cleaned.endswith(sfx):
74
+ cleaned = cleaned[: -len(sfx)].rstrip()
75
+ changed = True
76
+ return cleaned
77
+
78
+
79
+ def _snippet(text, suffixes):
80
+ """One-line, suffix-stripped, truncated rendering of a reply."""
81
+ t = _strip_suffixes((text or "").strip(), suffixes)
82
+ t = " ".join(t.split()) # collapse newlines/runs of whitespace
83
+ if len(t) > SNIPPET_CHARS:
84
+ t = t[: SNIPPET_CHARS - 1].rstrip() + "…"
85
+ return t
86
+
87
+
88
+ def build_block(platform, limit):
89
+ """Return the prompt block string, or "" when nothing to show."""
90
+ resp = api_get(
91
+ "/api/v1/posts",
92
+ query={
93
+ "platform": platform,
94
+ "status": "active",
95
+ "order_by": "posted_at",
96
+ "order_dir": "desc",
97
+ "limit": str(limit),
98
+ },
99
+ )
100
+ rows = ((resp or {}).get("data") or {}).get("posts") or []
101
+ suffixes = _load_active_campaign_suffixes()
102
+ items = []
103
+ for r in rows:
104
+ snip = _snippet(r.get("our_content"), suffixes)
105
+ if not snip:
106
+ continue
107
+ date = str(r.get("posted_at") or "")[:10]
108
+ proj = r.get("project_name") or "(no project)"
109
+ items.append(f"{len(items) + 1}. [{date} | {proj}] {snip}")
110
+ if len(items) >= limit:
111
+ break
112
+ if not items:
113
+ return ""
114
+ header = (
115
+ "## YOUR RECENT REPLIES (cross-cycle anti-repetition; NEGATIVE examples)\n"
116
+ f"The {len(items)} most recent replies this account posted, all projects. "
117
+ "This is what you ALREADY sound like. It is NOT a list to imitate. "
118
+ "Hard rules for every draft this cycle:\n"
119
+ "- Do NOT reuse any opener below (the first 6-8 words' shape counts, "
120
+ "not just the exact words).\n"
121
+ "- Do NOT reuse their sentence skeletons, rhetorical moves, or pet "
122
+ "phrases (recurring words like 'actually', 'the real X', copula "
123
+ "reframes 'X is the Y').\n"
124
+ "- If a draft you are writing starts to echo any entry below, stop "
125
+ "and rewrite it from a different entry point.\n"
126
+ )
127
+ return header + "\n".join(items)
128
+
129
+
130
+ def main():
131
+ parser = argparse.ArgumentParser(
132
+ description="Render the cross-cycle recent-self-replies prompt block")
133
+ parser.add_argument("--platform", default="twitter")
134
+ parser.add_argument("--limit", type=int, default=20)
135
+ args = parser.parse_args()
136
+ try:
137
+ block = build_block(args.platform, max(1, min(args.limit, 50)))
138
+ except Exception as e:
139
+ print(f"[recent_self_posts] failed: {e!r}", file=sys.stderr)
140
+ block = ""
141
+ if block:
142
+ print(block)
143
+
144
+
145
+ if __name__ == "__main__":
146
+ main()
@@ -460,7 +460,21 @@ if [[ "$DO_NPM" == "1" ]]; then
460
460
  fs.writeFileSync(p, JSON.stringify(j,null,2)+'\n');
461
461
  console.log(' temp copy renamed to $S4L_ALIAS_PKG@'+j.version+' (repo package.json untouched)');
462
462
  " \
463
- && ( cd "$S4L_PUB_DIR/package" && npm publish --access=public ${NPM_TAG_ARGS[@]+"${NPM_TAG_ARGS[@]}"} ); then
463
+ && (
464
+ # The registry's large-upload path flakes (TLS bad record mac / EPIPE)
465
+ # often enough that one attempt let the alias lane silently drift for
466
+ # three rc's (rc.16/21/22, caught 2026-07-10). Retry, and between
467
+ # attempts check whether the upload actually landed despite the
468
+ # client-side error (EPIPE can be a false negative).
469
+ cd "$S4L_PUB_DIR/package" || exit 1
470
+ for _try in 1 2 3; do
471
+ npm publish --access=public ${NPM_TAG_ARGS[@]+"${NPM_TAG_ARGS[@]}"} && exit 0
472
+ sleep 3
473
+ [[ "$(curl -s -o /dev/null -w '%{http_code}' "$S4L_ALIAS_URL")" == "200" ]] && exit 0
474
+ echo " dual-publish attempt $_try/3 failed" >&2
475
+ done
476
+ exit 1
477
+ ); then
464
478
  for _ in 1 2 3 4 5; do
465
479
  sleep 2
466
480
  [[ "$(curl -s -o /dev/null -w '%{http_code}' "$S4L_ALIAS_URL")" == "200" ]] && break
@@ -58,14 +58,15 @@ Usage:
58
58
  s4l_mode.py split # print the personal-brand share (0.0-1.0)
59
59
  s4l_mode.py split <value> # set it; accepts 70, 70%, or 0.7
60
60
 
61
- Draft-only (2026-07-06): a third, independent flag in mode.json, DEFAULT ON.
62
- While ON (the normal state), every cycle stops before posting and its drafts
63
- become review cards. When an OPERATOR turns it off, run-draft-and-publish.sh
64
- runs promotion-lane cycles with DRAFT_ONLY=0 so they POST autonomously (the
65
- rolling virality bar is the quality gate). Persona-lane cycles ALWAYS stay
66
- draft-only (review cards) regardless. Deliberately NOT exposed on any user
67
- surface (no MCP tool param, no menubar toggle): this CLI and mode.json are the
68
- only way to flip it.
61
+ Draft-only (2026-07-06, single global switch as of 2026-07-08): a third,
62
+ independent flag in mode.json, DEFAULT ON. While ON (the normal state), every
63
+ cycle stops before posting and its drafts become review cards. When an
64
+ OPERATOR turns it off, run-draft-and-publish.sh runs EVERY lane (promotion AND
65
+ personal_brand) with DRAFT_ONLY=0 so they POST autonomously (promotion
66
+ additionally runs behind the rolling virality bar). This applies uniformly
67
+ across lanes; persona-lane cycles do NOT stay draft-only when the flag is off.
68
+ Deliberately NOT exposed on any user surface (no MCP tool param, no menubar
69
+ toggle): this CLI and mode.json are the only way to flip it.
69
70
  `env` additionally exports S4L_CYCLE_LANE=<lane> for both lanes so the wrapper
70
71
  knows which lane this cycle is without re-deriving it.
71
72