@m13v/s4l 1.7.4-rc.13 → 1.7.4-rc.16

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.4-rc.13",
3
- "installedAt": "2026-07-11T02:59:38.374Z"
2
+ "version": "1.7.4-rc.16",
3
+ "installedAt": "2026-07-11T18:28:11.012Z"
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.4-rc.13",
5
+ "version": "1.7.4-rc.16",
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.4-rc.13",
3
+ "version": "1.7.4-rc.16",
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.4-rc.13",
3
+ "version": "1.7.4-rc.16",
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",
@@ -160,4 +160,4 @@
160
160
  "pg": "^8.20.0",
161
161
  "ws": "^8.0.0"
162
162
  }
163
- }
163
+ }
@@ -55,15 +55,24 @@ DESCRIPTIONS = {
55
55
  ),
56
56
  },
57
57
  "draft_prompt": {
58
+ "treatment_v3": (
59
+ "style-as-form: the assigned style is the binding FORM (defining "
60
+ "move + per-style length + self-check), learned preferences apply "
61
+ "inside it; keeps the v2 skeleton ban"
62
+ ),
63
+ "control_v3": (
64
+ "plain draft directive, uniform length clamp, no structure ban"
65
+ ),
66
+ # v2 arms (skeleton-ban) retired 2026-07-10; v1 arms
67
+ # (decoupled-product-pivot) retired 2026-07-06; kept so any
68
+ # straggler card from an old plan still explains itself.
58
69
  "treatment_v2": (
59
- "skeleton ban: forbids the concede-then-reverse "
70
+ "v2, retired: skeleton ban, forbids the concede-then-reverse "
60
71
  '"easy X / hard Y" structure and forces varied entry points'
61
72
  ),
62
73
  "control_v2": (
63
- "current draft directive (style + product pivot), no structure ban"
74
+ "v2, retired: draft directive (style + product pivot), no structure ban"
64
75
  ),
65
- # v1 arms (decoupled-product-pivot) retired 2026-07-06; kept so any
66
- # straggler card from an old plan still explains itself.
67
76
  "treatment": "v1, retired: product pivot decoupled from the reply",
68
77
  "control": "v1, retired: original draft directive",
69
78
  },
@@ -113,8 +122,9 @@ def collect(env=None):
113
122
  if var.startswith(ENV_PREFIX) and (v or "").strip():
114
123
  out[var[len(ENV_PREFIX):].lower()] = v.strip()
115
124
  # 2026-07-06: the personal_brand persona directive is now ARM-AWARE in
116
- # run-twitter-cycle.sh (treatment_v2 adds the concede-then-reverse skeleton ban,
117
- # control_v2 does not), so the assigned draft_prompt arm DOES touch persona
125
+ # run-twitter-cycle.sh (treatment_v3 adds the skeleton ban + two-layer
126
+ # style/preferences contract, control_v3 does not), so the assigned
127
+ # draft_prompt arm DOES touch persona
118
128
  # drafts. Keep it stamped so the arm surfaces on persona cards and the per-arm
119
129
  # readout covers both lanes. (Previously dropped here because the persona
120
130
  # directive overrode both arms wholesale; that is no longer the case.)
@@ -144,6 +144,59 @@ def _render_media_block(media) -> str:
144
144
  )
145
145
 
146
146
 
147
+ def _build_chain_block(row) -> str:
148
+ """Conversation chain reconstructed from replies.parent_reply_id linkage.
149
+
150
+ Walks ancestors bottom-up via GET /api/v1/replies/:id and renders the
151
+ chain root-first, each hop showing the inbound comment and (when we
152
+ responded) our reply. Empty string when the row has no parent linkage:
153
+ the root post itself already rides PENDING_DATA via the posts JOIN
154
+ (our_content / thread_title), so a chain block would add nothing.
155
+
156
+ Like counterparty_history_block, the block is self-titled and lands
157
+ inline in PENDING_DATA — no shell-side prompt change needed.
158
+ """
159
+ parent_id = row.get("parent_reply_id")
160
+ if not parent_id:
161
+ return ""
162
+ hops = []
163
+ seen = set()
164
+ cur = parent_id
165
+ for _ in range(10):
166
+ if not cur or cur in seen:
167
+ break
168
+ seen.add(cur)
169
+ try:
170
+ resp = api_get(f"/api/v1/replies/{cur}")
171
+ except Exception:
172
+ break
173
+ r = (resp.get("data") or {}).get("reply") or {}
174
+ if not r:
175
+ break
176
+ hops.append(r)
177
+ cur = r.get("parent_reply_id")
178
+ if not hops:
179
+ return ""
180
+
181
+ def _one_line(text):
182
+ return " ".join((text or "").split())
183
+
184
+ lines = [
185
+ "## Conversation chain (reconstructed from our DB; root first — "
186
+ "the row you are drafting for replies to the LAST message)"
187
+ ]
188
+ for r in reversed(hops):
189
+ lines.append(f"@{r.get('their_author') or '?'}: {_one_line(r.get('their_content'))}")
190
+ ours = _one_line(r.get("our_reply_content"))
191
+ if ours:
192
+ lines.append(f" our reply: {ours}")
193
+ lines.append(
194
+ f"@{row.get('their_author') or '?'}: {_one_line(row.get('their_content'))}"
195
+ " <- you are replying to this"
196
+ )
197
+ return "\n".join(lines)
198
+
199
+
147
200
  def cmd_pending_data(batch_size: int) -> int:
148
201
  try:
149
202
  from account_resolver import resolve as _resolve_account # noqa: WPS433
@@ -173,38 +226,50 @@ def cmd_pending_data(batch_size: int) -> int:
173
226
  # top slot then and get enriched.
174
227
  ENRICH_TOP_N = 60
175
228
  history_blocks = [""] * len(rows)
229
+ chain_blocks = [""] * len(rows)
176
230
  try:
177
231
  from concurrent.futures import ThreadPoolExecutor
178
232
  from counterparty_history import get_counterparty_history_block
179
233
 
180
234
  def _enrich(r):
181
235
  author = r.get("their_author")
182
- if not author:
183
- return ""
236
+ history = ""
237
+ if author:
238
+ try:
239
+ _disengage, history = get_counterparty_history_block(
240
+ platform="x",
241
+ author=author,
242
+ current_post_id=r.get("post_id"),
243
+ current_reply_id=r.get("id"),
244
+ )
245
+ history = history or ""
246
+ except Exception as e:
247
+ print(
248
+ f"[engage_twitter_helper] counterparty_history failed "
249
+ f"for @{author}: {e}",
250
+ file=sys.stderr,
251
+ )
184
252
  try:
185
- _disengage, block = get_counterparty_history_block(
186
- platform="x",
187
- author=author,
188
- current_post_id=r.get("post_id"),
189
- current_reply_id=r.get("id"),
190
- )
191
- return block or ""
253
+ chain = _build_chain_block(r)
192
254
  except Exception as e:
193
255
  print(
194
- f"[engage_twitter_helper] counterparty_history failed "
195
- f"for @{author}: {e}",
256
+ f"[engage_twitter_helper] chain block failed "
257
+ f"for reply {r.get('id')}: {e}",
196
258
  file=sys.stderr,
197
259
  )
198
- return ""
260
+ chain = ""
261
+ return (history, chain)
199
262
 
200
263
  top_rows = rows[:ENRICH_TOP_N]
201
264
  with ThreadPoolExecutor(max_workers=8) as ex:
202
- for idx, block in enumerate(ex.map(_enrich, top_rows)):
203
- history_blocks[idx] = block
265
+ for idx, (history, chain) in enumerate(ex.map(_enrich, top_rows)):
266
+ history_blocks[idx] = history
267
+ chain_blocks[idx] = chain
204
268
  non_empty = sum(1 for b in history_blocks if b)
269
+ chains_non_empty = sum(1 for b in chain_blocks if b)
205
270
  print(
206
- f"[engage_twitter_helper] counterparty_history enriched "
207
- f"{len(top_rows)}/{len(rows)} rows ({non_empty} with non-empty block)",
271
+ f"[engage_twitter_helper] enriched {len(top_rows)}/{len(rows)} rows "
272
+ f"(history={non_empty}, chain={chains_non_empty} non-empty)",
208
273
  file=sys.stderr,
209
274
  )
210
275
  except Exception as e:
@@ -215,7 +280,7 @@ def cmd_pending_data(batch_size: int) -> int:
215
280
  )
216
281
 
217
282
  out = []
218
- for r, history_block in zip(rows, history_blocks):
283
+ for r, history_block, chain_block in zip(rows, history_blocks, chain_blocks):
219
284
  out.append({
220
285
  "id": r.get("id"),
221
286
  "platform": r.get("platform"),
@@ -231,6 +296,7 @@ def cmd_pending_data(batch_size: int) -> int:
231
296
  "is_our_original_post": int(r.get("is_our_original_post") or 0),
232
297
  "project_name": r.get("project_name"),
233
298
  "counterparty_history_block": history_block,
299
+ "conversation_chain_block": chain_block,
234
300
  "their_media_block": _render_media_block(r.get("their_media")),
235
301
  })
236
302
  # json_agg(...) returns null when the array is empty; engage-twitter.sh's
@@ -1464,38 +1464,53 @@ def get_assigned_style_prompt(platform, assignment, context="posting"):
1464
1464
  lines = []
1465
1465
 
1466
1466
  if assignment["mode"] == "use":
1467
- lines.append(f"## Your assigned engagement style: **{assignment['style']}**")
1468
- lines.append("")
1469
- lines.append(
1470
- f"This style was selected by the picker (weighted by live "
1471
- f"click-driven performance across {platform}). Use it. Do not "
1472
- f"swap it for a different listed style."
1473
- )
1474
- lines.append("")
1475
- lines.append(f"Platform tone: {policy.get('note', '')}")
1476
- lines.append("")
1477
- lines.append(f"**{assignment['style']}**: {assignment.get('description', '')}")
1478
- if assignment.get("example"):
1479
- lines.append(f' Example: "{assignment["example"]}"')
1480
- if assignment.get("note"):
1481
- lines.append(f" Note: {assignment['note']}")
1482
- # LENGTH A/B CONCLUDED 2026-06-04: control won, so the prompt always
1483
- # uses the legacy generic length guidance. The treatment's per-style
1484
- # target prompt remains preserved only in the shipped experiment card.
1485
- #
1486
- # EXCEPTION (2026-07-11, Draft-B explore slot): exploration
1487
- # assignments (pick_exploration_style, marked by `source`) honor the
1488
- # style's own target_chars so the A/B pair diverges on the length
1489
- # axis too. The uniform clamp flattened every draft to the same
1490
- # 2-sentence shape (user: "the older drafts looked all very similar
1491
- # in terms of the length"). The 06-04 conclusion still governs the
1492
- # scored path, which never carries `source`.
1493
- _explore_tc = assignment.get("target_chars")
1494
- if (assignment.get("source") in ("human_derived", "model_invented")
1495
- and _explore_tc):
1467
+ # Draft-prompt A/B v3 (style-as-form, 2026-07-10): the treatment_v3
1468
+ # arm renders the style as the BINDING FORM of the draft (defining
1469
+ # move + per-style length for EVERY assignment + end-of-block
1470
+ # self-check + two-layer learned_preferences contract). The arm is
1471
+ # read from the env HERE because this block is rendered inside the
1472
+ # cycle process where run-twitter-cycle.sh assigns and exports
1473
+ # S4L_DRAFT_PROMPT_VARIANT (stamp-at-source: that same process also
1474
+ # stamps the arm onto every plan candidate via active_experiments).
1475
+ # Downstream/post-time consumers never re-read this env. Unset or
1476
+ # control_v3 (and every non-twitter caller, which never has the env)
1477
+ # renders the legacy block below unchanged.
1478
+ _dp_arm = (os.environ.get("S4L_DRAFT_PROMPT_VARIANT") or "").strip()
1479
+ if _dp_arm == "treatment_v3":
1480
+ _tc = assignment.get("target_chars") or DEFAULT_TARGET_CHARS
1481
+ lines.append(
1482
+ f"## Your assigned engagement style: **{assignment['style']}** "
1483
+ "(this is the FORM of the draft, not a flavor hint)"
1484
+ )
1485
+ lines.append("")
1486
+ lines.append(
1487
+ f"This style was selected by the picker (weighted by live "
1488
+ f"click-driven performance across {platform}). Use it. Do not "
1489
+ f"swap it for a different listed style."
1490
+ )
1491
+ lines.append("")
1492
+ lines.append(f"Platform tone: {policy.get('note', '')}")
1493
+ lines.append("")
1494
+ lines.append(f"**{assignment['style']}**: {assignment.get('description', '')}")
1495
+ if assignment.get("example"):
1496
+ lines.append(f' Example: "{assignment["example"]}"')
1497
+ if assignment.get("note"):
1498
+ lines.append(f" Note: {assignment['note']}")
1499
+ lines.append("")
1500
+ lines.append(
1501
+ "Commit to the form BEFORE writing. From the description and "
1502
+ "example above, identify the style's DEFINING MOVE (the one "
1503
+ "thing a draft in this style must contain: a specific number, "
1504
+ "a question, a flat disagreement, a confession, whatever the "
1505
+ "description names) and build the reply around that move. The "
1506
+ "test: with the topic removed, a reader should be able to "
1507
+ "identify this style from the draft's shape alone. If your "
1508
+ "draft would read the same under any other style name, it "
1509
+ "does not conform; rewrite it."
1510
+ )
1496
1511
  lines.append("")
1497
1512
  lines.append(
1498
- f"**LENGTH: aim for about {int(_explore_tc)} characters** "
1513
+ f"**LENGTH: aim for about {int(_tc)} characters** "
1499
1514
  "(this style's own winning length; within about 30% either "
1500
1515
  "way is fine, never above 250). Let the target set the form: "
1501
1516
  "a very short target means one clipped line, a long one can "
@@ -1503,14 +1518,72 @@ def get_assigned_style_prompt(platform, assignment, context="posting"):
1503
1518
  "This applies to the comment text only; any link/CTA the "
1504
1519
  "system appends afterward is separate."
1505
1520
  )
1521
+ lines.append("")
1522
+ lines.append(
1523
+ "Learned user preferences (the learned_preferences block in "
1524
+ "the project context) apply INSIDE this form: they control "
1525
+ "voice, wording, and what to avoid; they do not replace the "
1526
+ "style's structure or length. If a preference seems to "
1527
+ "conflict with the style's defining move, keep the move and "
1528
+ "satisfy the preference within it."
1529
+ )
1530
+ lines.append("")
1531
+ lines.append(
1532
+ "SELF-CHECK before returning: (1) does the draft contain the "
1533
+ "style's defining move? (2) is the length within about 30% "
1534
+ "of the target? If either fails, rewrite once."
1535
+ )
1506
1536
  else:
1537
+ lines.append(f"## Your assigned engagement style: **{assignment['style']}**")
1507
1538
  lines.append("")
1508
1539
  lines.append(
1509
- "**LENGTH: keep it tight.** One or two sentences, well under the "
1510
- "250-character Twitter limit. A short, sharp reply almost always "
1511
- "beats a paragraph. This applies to the comment text only; any "
1512
- "link/CTA the system appends afterward is separate."
1540
+ f"This style was selected by the picker (weighted by live "
1541
+ f"click-driven performance across {platform}). Use it. Do not "
1542
+ f"swap it for a different listed style."
1513
1543
  )
1544
+ lines.append("")
1545
+ lines.append(f"Platform tone: {policy.get('note', '')}")
1546
+ lines.append("")
1547
+ lines.append(f"**{assignment['style']}**: {assignment.get('description', '')}")
1548
+ if assignment.get("example"):
1549
+ lines.append(f' Example: "{assignment["example"]}"')
1550
+ if assignment.get("note"):
1551
+ lines.append(f" Note: {assignment['note']}")
1552
+ # LENGTH A/B CONCLUDED 2026-06-04: control won, so the prompt always
1553
+ # uses the legacy generic length guidance. The treatment's per-style
1554
+ # target prompt remains preserved only in the shipped experiment card.
1555
+ #
1556
+ # EXCEPTION (2026-07-11, Draft-B explore slot): exploration
1557
+ # assignments (pick_exploration_style, marked by `source`) honor the
1558
+ # style's own target_chars so the A/B pair diverges on the length
1559
+ # axis too. The uniform clamp flattened every draft to the same
1560
+ # 2-sentence shape (user: "the older drafts looked all very similar
1561
+ # in terms of the length"). The 06-04 conclusion still governs the
1562
+ # scored path, which never carries `source`.
1563
+ #
1564
+ # (The draft-prompt v3 treatment arm above supersedes this clamp
1565
+ # for its cycles; this legacy branch IS the v3 control arm.)
1566
+ _explore_tc = assignment.get("target_chars")
1567
+ if (assignment.get("source") in ("human_derived", "model_invented")
1568
+ and _explore_tc):
1569
+ lines.append("")
1570
+ lines.append(
1571
+ f"**LENGTH: aim for about {int(_explore_tc)} characters** "
1572
+ "(this style's own winning length; within about 30% either "
1573
+ "way is fine, never above 250). Let the target set the form: "
1574
+ "a very short target means one clipped line, a long one can "
1575
+ "breathe. Do NOT default to the usual two-sentence shape. "
1576
+ "This applies to the comment text only; any link/CTA the "
1577
+ "system appends afterward is separate."
1578
+ )
1579
+ else:
1580
+ lines.append("")
1581
+ lines.append(
1582
+ "**LENGTH: keep it tight.** One or two sentences, well under the "
1583
+ "250-character Twitter limit. A short, sharp reply almost always "
1584
+ "beats a paragraph. This applies to the comment text only; any "
1585
+ "link/CTA the system appends afterward is separate."
1586
+ )
1514
1587
  lines.append("")
1515
1588
  lines.append(
1516
1589
  'In your output JSON, set "engagement_style" to exactly '
@@ -87,11 +87,14 @@ def fetch_fxtwitter(handle, tweet_id):
87
87
  def walk_ancestors(handle, tweet_id, sleep_s):
88
88
  """Ancestor chain bottom-up: [(id, handle), ...] parent first, root last.
89
89
 
90
- Returns (chain, terminal) where terminal is 'root' when the walk reached a
91
- non-reply tweet, or 'cut' when a hop was deleted/protected/transient (the
92
- chain up to that point is still usable, but root attribution is not).
90
+ Returns (focal, chain, terminal): focal is the fetched tweet object for
91
+ tweet_id itself (None when it is gone/unfetchable — needed for quote
92
+ linkage), terminal is 'root' when the walk reached a non-reply tweet,
93
+ 'gone'/'transient' when a hop was deleted/protected/errored (the chain up
94
+ to that point is still usable, but root attribution is not), or 'hop_cap'.
93
95
  """
94
96
  chain = []
97
+ focal = None
95
98
  cur_handle, cur_id = handle, tweet_id
96
99
  for _ in range(MAX_HOPS):
97
100
  status, tweet = fetch_fxtwitter(cur_handle, cur_id)
@@ -99,14 +102,16 @@ def walk_ancestors(handle, tweet_id, sleep_s):
99
102
  if status != "ok":
100
103
  # 'gone' is terminal (deleted/protected); 'transient' must NOT be
101
104
  # remembered, the next run retries it.
102
- return chain, status
105
+ return focal, chain, status
106
+ if focal is None:
107
+ focal = tweet
103
108
  parent_id = tweet.get("replying_to_status")
104
109
  parent_handle = tweet.get("replying_to") or ""
105
110
  if not parent_id:
106
- return chain, "root"
111
+ return focal, chain, "root"
107
112
  chain.append((str(parent_id), parent_handle))
108
113
  cur_handle, cur_id = parent_handle, parent_id
109
- return chain, "hop_cap"
114
+ return focal, chain, "hop_cap"
110
115
 
111
116
 
112
117
  def lookup_our_post(tweet_id):
@@ -126,6 +131,16 @@ def lookup_tracked_reply(tweet_id):
126
131
  return rows[0] if rows else None
127
132
 
128
133
 
134
+ def lookup_our_posted_reply(tweet_id):
135
+ """Reply row where WE authored the tweet (our_reply_id / our_reply_url)."""
136
+ resp = api_get(
137
+ "/api/v1/replies",
138
+ query={"platform": "x", "our_reply_status_id": str(tweet_id), "limit": "1"},
139
+ )
140
+ rows = (resp.get("data") or {}).get("replies") or []
141
+ return rows[0] if rows else None
142
+
143
+
129
144
  def fetch_work(limit, our_account=None, ids=None, before_id=None):
130
145
  if ids:
131
146
  out = []
@@ -158,13 +173,12 @@ def enrich_row(row, state, sleep_s, dry_run):
158
173
  if state.get(tid):
159
174
  return "state_skip"
160
175
 
161
- chain, terminal = walk_ancestors(handle, tid, sleep_s)
162
- if not chain:
176
+ focal, chain, terminal = walk_ancestors(handle, tid, sleep_s)
177
+ if focal is None:
163
178
  if terminal == "transient":
164
179
  return "transient" # retry next run, no state write
165
- # Deleted/protected focal tweet, or a standalone mention (not a reply).
166
- state[tid] = "gone" if terminal == "gone" else "not_a_reply"
167
- return state[tid]
180
+ state[tid] = "gone" # deleted/protected focal tweet
181
+ return "gone"
168
182
 
169
183
  patch = {}
170
184
  matched_post = None
@@ -178,24 +192,56 @@ def enrich_row(row, state, sleep_s, dry_run):
178
192
  if not row.get("project_name") and matched_post.get("project_name"):
179
193
  patch["project_name"] = matched_post["project_name"]
180
194
 
181
- parent_id, _parent_handle = chain[0]
182
- tracked = lookup_tracked_reply(parent_id)
183
- if tracked and tracked["id"] != rid:
184
- patch["parent_reply_id"] = tracked["id"]
185
- patch["depth"] = (tracked.get("depth") or 1) + 1
195
+ if chain:
196
+ parent_id, _parent_handle = chain[0]
197
+ # Immediate parent: another inbound reply we track, or a reply WE
198
+ # posted (the dominant case: a fan replying to our engagement reply).
199
+ tracked = lookup_tracked_reply(parent_id)
200
+ if tracked and tracked["id"] != rid:
201
+ patch["parent_reply_id"] = tracked["id"]
202
+ patch["depth"] = (tracked.get("depth") or 1) + 1
203
+ else:
204
+ ours = lookup_our_posted_reply(parent_id)
205
+ if ours and ours["id"] != rid:
206
+ patch["parent_reply_id"] = ours["id"]
207
+ patch["depth"] = (ours.get("depth") or 1) + 1
208
+ if "post_id" not in patch and ours.get("post_id"):
209
+ patch["post_id"] = ours["post_id"]
210
+ if not row.get("project_name") and not patch.get("project_name") \
211
+ and ours.get("project_name"):
212
+ patch["project_name"] = ours["project_name"]
213
+
214
+ # Quote linkage: a quote-tweet of our post (or of a reply we posted) is
215
+ # engagement on our content even when replying_to_status is empty.
216
+ quote_id = str(((focal.get("quote") or {}).get("id")) or "")
217
+ if quote_id and "post_id" not in patch:
218
+ qpost = lookup_our_post(quote_id)
219
+ if qpost:
220
+ patch["post_id"] = qpost["id"]
221
+ if not row.get("project_name") and not patch.get("project_name") \
222
+ and qpost.get("project_name"):
223
+ patch["project_name"] = qpost["project_name"]
224
+ elif "parent_reply_id" not in patch:
225
+ qours = lookup_our_posted_reply(quote_id)
226
+ if qours and qours["id"] != rid:
227
+ patch["parent_reply_id"] = qours["id"]
228
+ patch["depth"] = (qours.get("depth") or 1) + 1
229
+ if qours.get("post_id"):
230
+ patch["post_id"] = qours["post_id"]
186
231
 
187
232
  if terminal == "root":
188
- root_handle = (chain[-1][1] or "").lstrip("@")
189
- if root_handle and not row.get("thread_author_handle"):
233
+ root_handle = (chain[-1][1] if chain else (focal.get("author") or {}).get("screen_name") or "")
234
+ root_handle = (root_handle or "").lstrip("@")
235
+ if chain and root_handle and not row.get("thread_author_handle"):
190
236
  patch["thread_author_handle"] = root_handle
191
237
 
192
238
  if not patch:
193
239
  if terminal == "transient":
194
240
  return "transient" # incomplete walk; retry next run
195
- # Full chain walked, nothing of ours in it: a foreign thread. Remember
196
- # so we don't rewalk it every run.
197
- state[tid] = "no_link"
198
- return "no_link"
241
+ # Chain walked to root / cut by a deleted ancestor / not a reply at
242
+ # all, and nothing of ours anywhere in it: remember permanently.
243
+ state[tid] = "no_link" if chain else "not_a_reply"
244
+ return state[tid]
199
245
 
200
246
  if dry_run:
201
247
  print(f" [DRY] reply {rid}: {json.dumps(patch)}")
@@ -193,11 +193,23 @@ def build_prompt(platform, replies, reserved_names):
193
193
  lines.append("")
194
194
  lines.append(
195
195
  "These replies all WON the thread (top of the conversation by likes). "
196
- "Find the shared pattern that makes them work the rhetorical move, "
196
+ "Find the shared pattern that makes them work: the rhetorical move, "
197
197
  "the structural shape, the relationship to the OP. Most winners "
198
198
  "share ONE pattern; that pattern is your new engagement style."
199
199
  )
200
200
  lines.append("")
201
+ lines.append(
202
+ "PRESERVE THE STRUCTURAL FINGERPRINT. Do not smooth the pattern "
203
+ "into a generic one-liner ('add a thoughtful counterpoint'); keep "
204
+ "what the winning replies actually DO on the page: how the first "
205
+ "words enter (lowercase noun, a number, the question itself, a "
206
+ "quoted phrase), how many sentences and of what shape (one clipped "
207
+ "line, two short + one long, a fragment), where the punch sits, "
208
+ "and what the reply refuses to do (no greeting, no hedge, no "
209
+ "summary). The char counts shown per reply are part of the "
210
+ "fingerprint; notice where the winners cluster."
211
+ )
212
+ lines.append("")
201
213
  lines.append(
202
214
  "Ignore: replies that win because of follower count, fame, or "
203
215
  "non-repeatable luck. Focus on the structural move that we (a "
@@ -209,14 +221,15 @@ def build_prompt(platform, replies, reserved_names):
209
221
  )
210
222
  lines.append("")
211
223
  for i, r in enumerate(replies, 1):
224
+ content = r.get("reply_content") or ""
212
225
  lines.append(
213
226
  f"### #{i} (likes={r['likes']}, replies={r['replies_count']}, "
214
- f"rt={r['retweets']})"
227
+ f"rt={r['retweets']}, chars={len(content.strip())})"
215
228
  )
216
229
  lines.append(f"Thread: {r['thread_url']}")
217
230
  handle = r.get("reply_author_handle") or "(unknown)"
218
231
  lines.append(f"Reply by @{handle}:")
219
- lines.append(f"> {r['reply_content']}")
232
+ lines.append(f"> {content}")
220
233
  lines.append("")
221
234
 
222
235
  lines.append("## Schema (match exactly)")
@@ -230,7 +243,12 @@ def build_prompt(platform, replies, reserved_names):
230
243
  lines.append("```")
231
244
  lines.append("{")
232
245
  lines.append(' "name": "<snake_case_name>",')
233
- lines.append(' "description": "<one to three sentences describing the style>",')
246
+ lines.append(
247
+ ' "description": "<one to three sentences carrying the structural '
248
+ "fingerprint: the DEFINING MOVE (the one thing every draft in this "
249
+ "style must contain), the OPENING (how the first words enter), and "
250
+ 'the sentence shape>",'
251
+ )
234
252
  lines.append(' "example": "<one short OP + reply pair demonstrating the style>",')
235
253
  lines.append(' "best_in": {')
236
254
  lines.append(f' "{platform}": ["<short context label>", ...],')
@@ -255,8 +273,11 @@ def build_prompt(platform, replies, reserved_names):
255
273
  "MOVE (e.g. `mirror_and_extend`, `flip_to_alt`, not `good_reply`)."
256
274
  )
257
275
  lines.append(
258
- "3. The description should make the style copyable: a future model "
259
- "reading just that one sentence should know what to write."
276
+ "3. The description must make the style copyable AS A FORM: a future "
277
+ "model reading only the description must know the defining move, how "
278
+ "to open, and what sentence shape to write. 'Add a sharp "
279
+ "counterpoint' fails this test; 'one clipped sentence, no greeting, "
280
+ "opens with the concrete number the OP left out' passes."
260
281
  )
261
282
  lines.append(
262
283
  "4. The example should be a realistic OP + reply pair, not lifted "
@@ -270,7 +291,10 @@ def build_prompt(platform, replies, reserved_names):
270
291
  lines.append(
271
292
  "6. NEVER propose a style about including a product, a URL, or a "
272
293
  "mechanism. Our link-tail layer handles that downstream. The style "
273
- "is about the text BEFORE the link."
294
+ "is about the text BEFORE the link. The note MAY state in one "
295
+ "clause how a plain product mention would enter this style if ever "
296
+ "(e.g. 'product name fits as the concrete example slot; never in "
297
+ "the opening'), but no URLs or link mechanics."
274
298
  )
275
299
  return "\n".join(lines)
276
300
 
@@ -15,12 +15,26 @@ This job runs OUTSIDE any drafting context, on the operator Mac only:
15
15
  style), so invention must NOT fan out per install the way topic
16
16
  invention does. One central daily run is the correct scope; that is
17
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.
18
+ - VERBALIZED SAMPLING (2026-07-10, arXiv 2510.01171): a single "invent
19
+ one style" ask reliably returns the modal answer (typicality bias from
20
+ preference training), which is why 653 of ~950 registered styles
21
+ cluster at 53-98 target_chars and one rhetorical family. Instead the
22
+ prompt asks for N_CANDIDATES candidate styles WITH a verbalized
23
+ typicality probability each, and selection takes the LOWEST-probability
24
+ candidate that survives dedup: we sample the tail of the distribution,
25
+ not the mode. This is the principled fix; the family blocklist below is
26
+ only a backstop.
27
+ - The existing registry is framed as OCCUPIED NICHES to stay out of
28
+ (quality-diversity framing, arXiv 2310.13032), NOT as reference
29
+ inspiration; showing top performers as exemplars is what anchored the
30
+ retired inline path to the champion family.
31
+ - target_chars DIVERSITY: the prompt shows live length-band occupancy
32
+ and requires candidates to spread across bands, biased to the
33
+ least-occupied ones, so invention stops minting 80-char styles.
21
34
  - 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.
35
+ reframe-family heuristic) rejects near-clones; when every candidate is
36
+ rejected we re-prompt with a grown avoid-list, at most DUPE_RETRIES
37
+ times.
24
38
  - Accepted styles are registered via engagement_styles.register_style
25
39
  (kind='model_invented'), same as the retired inline path, so pickers
26
40
  see them on their next tick with zero other wiring.
@@ -52,6 +66,12 @@ SCRIPT_TAG = "invent-styles"
52
66
  CALL_TIMEOUT_SEC = 420
53
67
  DUPE_RETRIES = 3
54
68
  SIMILARITY_THRESHOLD = 0.5 # Jaccard on description+example tokens
69
+ N_CANDIDATES = 5 # verbalized-sampling fan-out per call
70
+
71
+ # Length bands for target_chars diversity. Occupancy is computed live from
72
+ # the registry and shown in the prompt; candidates must spread across bands
73
+ # and bias toward the least-occupied ones.
74
+ LENGTH_BANDS = [(30, 60), (60, 100), (100, 150), (150, 200), (200, 250)]
55
75
 
56
76
  # Heuristic markers of the saturated agree-then-relocate/reframe family.
57
77
  # A proposal whose description/example leans on these is a clone of the
@@ -98,6 +118,23 @@ def _find_near_dupe(proposal, universe):
98
118
  return None
99
119
 
100
120
 
121
+ def band_occupancy(universe):
122
+ """[(lo, hi, count), ...] for LENGTH_BANDS over the registry's
123
+ target_chars. The live histogram the prompt shows so candidates can
124
+ claim underrepresented bands."""
125
+ counts = [0] * len(LENGTH_BANDS)
126
+ for e in universe.values():
127
+ try:
128
+ tc = int(e.get("target_chars") or 0)
129
+ except (TypeError, ValueError):
130
+ continue
131
+ for i, (lo, hi) in enumerate(LENGTH_BANDS):
132
+ if lo <= tc < hi:
133
+ counts[i] += 1
134
+ break
135
+ return [(lo, hi, counts[i]) for i, (lo, hi) in enumerate(LENGTH_BANDS)]
136
+
137
+
101
138
  def build_prompt(universe, avoid):
102
139
  names = sorted(universe.keys())
103
140
  # Detail only a bounded sample (prompt-size guard): the seeds plus the
@@ -117,25 +154,42 @@ def build_prompt(universe, avoid):
117
154
  "\nAlready proposed and REJECTED this run (do not resubmit or "
118
155
  "paraphrase): " + ", ".join(avoid) + "\n"
119
156
  )
157
+ bands = band_occupancy(universe)
158
+ band_lines = "\n".join(
159
+ f"- {lo}-{hi} chars: {count} existing styles"
160
+ f"{' <- UNDERREPRESENTED, prefer this band' if count == min(c for _, _, c in bands) else ''}"
161
+ for lo, hi, count in bands
162
+ )
120
163
  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
164
 
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.
165
+ The registry below is OCCUPIED TERRITORY, not inspiration. Your job is to find empty niches: structural families and length bands that no existing style covers. Do not riff on what is already there.
166
+
167
+ 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 propose any member of that family, however disguised.
123
168
 
124
169
  EXISTING STYLE NAMES ({len(names)} total):
125
170
  {", ".join(names)}
126
171
 
127
- REPRESENTATIVE DETAILS (sample):
172
+ REPRESENTATIVE DETAILS (occupied niches, sample):
128
173
  {chr(10).join(detailed)}
174
+
175
+ LENGTH-BAND OCCUPANCY (live registry histogram of target_chars):
176
+ {band_lines}
129
177
  {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.
178
+ TASK (verbalized sampling): generate {N_CANDIDATES} CANDIDATE styles. For each, verbalize a `probability`: how likely this exact style would be as a language model's single first answer to "invent a new reply style" (0.0-1.0, honest, they need not sum to 1). Deliberately include tail candidates: at least 3 of the {N_CANDIDATES} must have probability under 0.10, meaning genuinely atypical moves a model would almost never produce first. We will programmatically select from the LOW-probability tail, so the obvious candidates are effectively discards; put your creativity into the tail.
179
+
180
+ Diversity requirements across the {N_CANDIDATES} candidates:
181
+ - Each from a DIFFERENT structural family. Families worth mining (or others 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.
182
+ - Each in a DIFFERENT length band from the histogram above, biased toward the least-occupied bands. `target_chars` must be the actual length of your example, and the example must genuinely inhabit its band (a 200-char style is narrative, not a padded one-liner).
131
183
 
132
- Rules:
133
- - The example must read like a real human reply (lowercase ok), 40-220 chars, NO links, NO product names.
184
+ Per-candidate rules:
185
+ - `description` must state the style's DEFINING MOVE (the one thing every draft in this style must contain) and its OPENING (how the first words enter: e.g. lowercase noun, a number, the question itself). A future model reading only the description must know exactly what shape to write.
186
+ - `note` states when to use / when not to, and how a product mention would enter this style if ever (one clause; the link/CTA layer is downstream, so no URLs or link mechanics).
187
+ - The example must read like a real human reply (lowercase ok), NO links, NO product names.
134
188
  - 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.
189
+ - Every candidate is dedup-checked by token similarity against every existing style's description+example; if you cannot field {N_CANDIDATES} genuinely different moves, return the saturation envelope instead of forcing paraphrases.
136
190
 
137
191
  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>}}
192
+ {{"candidates": [{{"name": "snake_case_name", "description": "...", "example": "...", "note": "...", "why_existing_didnt_fit": "...", "target_chars": <int>, "probability": <float>}}, ...]}}
139
193
  {{"saturated": true, "reason": "..."}}"""
140
194
 
141
195
 
@@ -202,21 +256,43 @@ def main():
202
256
  print(f"[invent_styles] slot={slot} model reports saturation: "
203
257
  f"{obj.get('reason', '')[:200]}", file=sys.stderr)
204
258
  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)
259
+ candidates = obj.get("candidates")
260
+ if not isinstance(candidates, list) or not candidates:
261
+ print(f"[invent_styles] slot={slot} attempt={attempt} no "
262
+ f"candidates array in output", file=sys.stderr)
217
263
  continue
218
- accepted = {**obj, "name": name}
219
- break
264
+ # Verbalized-sampling selection: walk the tail first (ascending
265
+ # verbalized probability = least typical candidate first) and
266
+ # take the first that survives dedup. The modal candidates at
267
+ # the top of the distribution only get a chance if every tail
268
+ # candidate is a clone.
269
+ def _prob(c):
270
+ try:
271
+ return float(c.get("probability"))
272
+ except (TypeError, ValueError):
273
+ return 1.0 # unparseable prob sorts as maximally typical
274
+ for cand in sorted(candidates, key=_prob):
275
+ name = str(cand.get("name") or "").strip()
276
+ if not re.fullmatch(r"[a-z0-9_]{3,60}", name):
277
+ print(f"[invent_styles] slot={slot} bad name {name!r}; "
278
+ f"skipping candidate", file=sys.stderr)
279
+ avoid.append(name or "(unnamed)")
280
+ continue
281
+ dupe = _find_near_dupe({**cand, "name": name}, universe)
282
+ if dupe:
283
+ reason, existing = dupe
284
+ print(f"[invent_styles] slot={slot} rejected {name!r} "
285
+ f"(p={_prob(cand):.2f}): {reason} vs {existing}",
286
+ file=sys.stderr)
287
+ avoid.append(name)
288
+ continue
289
+ accepted = {**cand, "name": name}
290
+ print(f"[invent_styles] slot={slot} selected tail candidate "
291
+ f"{name!r} (p={_prob(cand):.2f}) from "
292
+ f"{len(candidates)} candidates", file=sys.stderr)
293
+ break
294
+ if accepted:
295
+ break
220
296
  if not accepted:
221
297
  continue
222
298
  if args.dry_run:
@@ -227,6 +303,7 @@ def main():
227
303
  {
228
304
  "description": accepted.get("description", ""),
229
305
  "example": accepted.get("example", ""),
306
+ "note": accepted.get("note", ""),
230
307
  "why_existing_didnt_fit": accepted.get("why_existing_didnt_fit", ""),
231
308
  "target_chars": accepted.get("target_chars"),
232
309
  },
@@ -0,0 +1,234 @@
1
+ #!/usr/bin/env python3
2
+ """LinkedIn posting cadence: 4 active days, then a mandatory 2-day break.
3
+
4
+ Per user instruction (2026-07-11): LinkedIn activity should not run more than
5
+ four days at a time before taking a two-day break. "Active day" is counted
6
+ only when real posting/engagement activity actually happened that day (an
7
+ outage day does not count toward the four), and the break pauses ALL LinkedIn
8
+ traffic, including the passive presence-check job, not just posting.
9
+
10
+ Every LinkedIn entrypoint (skill/run-linkedin.sh, engage-linkedin.sh,
11
+ engage-dm-replies.sh, dm-outreach-linkedin.sh, audit-linkedin.sh,
12
+ linkedin-presence.sh) already gates on the existence of ONE file:
13
+ ~/.claude/social-autoposter/linkedin.killswitch
14
+ That file is scripts/linkedin_killswitch.py's antibot killswitch. This module
15
+ reuses the SAME file for a scheduled break (signal="scheduled_break") so every
16
+ entrypoint pauses automatically, with zero edits to the locked entrypoint
17
+ scripts. It never overwrites a REAL antibot signal, and linkedin_killswitch.py
18
+ is patched (recover-check) to never try to auto-recover a scheduled_break.
19
+
20
+ State lives at ~/.claude/social-autoposter/linkedin_cadence.json:
21
+ {
22
+ "phase": "active" | "break",
23
+ "phase_started": "2026-07-11T20:00:00Z",
24
+ "active_days": ["2026-07-13", "2026-07-14", ...] # UTC dates with
25
+ # confirmed posts,
26
+ # only meaningful
27
+ # while phase=active
28
+ }
29
+
30
+ CLI:
31
+ python3 scripts/linkedin_cadence.py enforce # one tick; called every 15m
32
+ python3 scripts/linkedin_cadence.py status # print state (json)
33
+ """
34
+
35
+ import json
36
+ import os
37
+ import sys
38
+ from datetime import datetime, timedelta, timezone
39
+
40
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
41
+
42
+ import http_api # noqa: E402
43
+ import linkedin_killswitch as ks # noqa: E402
44
+
45
+ STATE_DIR = os.path.expanduser(
46
+ os.environ.get("LINKEDIN_KILLSWITCH_DIR", "~/.claude/social-autoposter")
47
+ )
48
+ STATE_FILE = os.path.expanduser(
49
+ os.environ.get("LINKEDIN_CADENCE_FILE", os.path.join(STATE_DIR, "linkedin_cadence.json"))
50
+ )
51
+
52
+ ACTIVE_DAYS_TARGET = int(os.environ.get("LINKEDIN_CADENCE_ACTIVE_DAYS", "4"))
53
+ BREAK_DAYS = int(os.environ.get("LINKEDIN_CADENCE_BREAK_DAYS", "2"))
54
+
55
+ SCHEDULED_BREAK_SIGNAL = "scheduled_break"
56
+
57
+
58
+ def _now():
59
+ return datetime.now(timezone.utc)
60
+
61
+
62
+ def _now_iso():
63
+ return _now().strftime("%Y-%m-%dT%H:%M:%SZ")
64
+
65
+
66
+ def _today_str():
67
+ return _now().date().isoformat()
68
+
69
+
70
+ def _parse_ts(ts):
71
+ return datetime.strptime(ts, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc)
72
+
73
+
74
+ def _ensure_dir():
75
+ os.makedirs(STATE_DIR, exist_ok=True)
76
+
77
+
78
+ def load_state():
79
+ try:
80
+ with open(STATE_FILE, "r") as f:
81
+ return json.load(f)
82
+ except Exception:
83
+ return None
84
+
85
+
86
+ def save_state(state):
87
+ _ensure_dir()
88
+ tmp = STATE_FILE + ".tmp"
89
+ with open(tmp, "w") as f:
90
+ json.dump(state, f, indent=2)
91
+ f.write("\n")
92
+ os.replace(tmp, STATE_FILE)
93
+
94
+
95
+ def _default_state_starting_break():
96
+ # First-ever run: user asked to start immediately with a two-day break.
97
+ return {"phase": "break", "phase_started": _now_iso(), "active_days": []}
98
+
99
+
100
+ def _today_post_count():
101
+ """LinkedIn posts made today (UTC), via the same API the dashboard uses.
102
+
103
+ Best-effort: returns None on any failure so callers can skip counting
104
+ this tick rather than wrongly recording 0 activity."""
105
+ try:
106
+ resp = http_api.api_get(
107
+ "/api/v1/dashboard/posts-per-day", {"days": 1, "platform": "linkedin"}
108
+ )
109
+ rows = (resp or {}).get("data", {}).get("rows", [])
110
+ today = _today_str()
111
+ for row in rows:
112
+ if row.get("day") == today:
113
+ return int(row.get("posts_made") or 0)
114
+ return 0
115
+ except Exception as exc:
116
+ print(f"[linkedin_cadence] WARN: posts-per-day query failed: {exc}", file=sys.stderr)
117
+ return None
118
+
119
+
120
+ def _pause_marker_set():
121
+ """Set the shared killswitch file for a scheduled break, unless a REAL
122
+ antibot signal is already in charge (never stomp a genuine block)."""
123
+ payload = ks.read()
124
+ if payload is None:
125
+ ks.engage(
126
+ signal=SCHEDULED_BREAK_SIGNAL,
127
+ detail="cadence: scheduled 2-day pause after 4 active days",
128
+ send_email=False,
129
+ )
130
+ print("[linkedin_cadence] pause marker set (scheduled_break)", file=sys.stderr)
131
+ elif payload.get("signal") == SCHEDULED_BREAK_SIGNAL:
132
+ pass # already set by us; idempotent, no trail spam
133
+ else:
134
+ print(
135
+ f"[linkedin_cadence] real killswitch already active (signal="
136
+ f"{payload.get('signal')!r}); deferring to it, not overwriting",
137
+ file=sys.stderr,
138
+ )
139
+
140
+
141
+ def _pause_marker_clear_if_ours():
142
+ payload = ks.read()
143
+ if payload is not None and payload.get("signal") == SCHEDULED_BREAK_SIGNAL:
144
+ ks.clear()
145
+ print("[linkedin_cadence] pause marker cleared (break ended)", file=sys.stderr)
146
+
147
+
148
+ def enforce():
149
+ state = load_state() or _default_state_starting_break()
150
+ if load_state() is None:
151
+ save_state(state)
152
+ print(
153
+ f"[linkedin_cadence] no prior state; starting BREAK phase now "
154
+ f"({BREAK_DAYS}d)",
155
+ file=sys.stderr,
156
+ )
157
+
158
+ now = _now()
159
+ phase = state["phase"]
160
+
161
+ if phase == "break":
162
+ started = _parse_ts(state["phase_started"])
163
+ elapsed = now - started
164
+ if elapsed >= timedelta(days=BREAK_DAYS):
165
+ state = {"phase": "active", "phase_started": _now_iso(), "active_days": []}
166
+ save_state(state)
167
+ _pause_marker_clear_if_ours()
168
+ print(
169
+ f"[linkedin_cadence] break ended after {elapsed}; switching to ACTIVE",
170
+ file=sys.stderr,
171
+ )
172
+ phase = "active"
173
+ else:
174
+ remaining = timedelta(days=BREAK_DAYS) - elapsed
175
+ _pause_marker_set()
176
+ print(
177
+ f"[linkedin_cadence] BREAK phase: {remaining} remaining",
178
+ file=sys.stderr,
179
+ )
180
+ return
181
+
182
+ # phase == "active"
183
+ real_block = ks.read()
184
+ if real_block is not None and real_block.get("signal") != SCHEDULED_BREAK_SIGNAL:
185
+ print(
186
+ f"[linkedin_cadence] account down for a real reason (signal="
187
+ f"{real_block.get('signal')!r}); not counting today, not pausing",
188
+ file=sys.stderr,
189
+ )
190
+ return
191
+
192
+ count = _today_post_count()
193
+ today = _today_str()
194
+ if count is not None and count > 0 and today not in state["active_days"]:
195
+ state["active_days"].append(today)
196
+ save_state(state)
197
+ print(
198
+ f"[linkedin_cadence] activity confirmed today ({count} posts); "
199
+ f"active_days={len(state['active_days'])}/{ACTIVE_DAYS_TARGET} "
200
+ f"{state['active_days']}",
201
+ file=sys.stderr,
202
+ )
203
+
204
+ if len(state["active_days"]) >= ACTIVE_DAYS_TARGET:
205
+ state = {"phase": "break", "phase_started": _now_iso(), "active_days": []}
206
+ save_state(state)
207
+ _pause_marker_set()
208
+ print(
209
+ f"[linkedin_cadence] {ACTIVE_DAYS_TARGET} active days reached; "
210
+ f"switching to BREAK for {BREAK_DAYS}d",
211
+ file=sys.stderr,
212
+ )
213
+ else:
214
+ print(
215
+ f"[linkedin_cadence] ACTIVE phase: "
216
+ f"{len(state['active_days'])}/{ACTIVE_DAYS_TARGET} active days so far",
217
+ file=sys.stderr,
218
+ )
219
+
220
+
221
+ def main():
222
+ if len(sys.argv) < 2 or sys.argv[1] not in ("enforce", "status"):
223
+ print("usage: linkedin_cadence.py [enforce|status]", file=sys.stderr)
224
+ sys.exit(2)
225
+ if sys.argv[1] == "status":
226
+ state = load_state()
227
+ print(json.dumps(state if state is not None else {"phase": None}, indent=2))
228
+ sys.exit(0)
229
+ enforce()
230
+ sys.exit(0)
231
+
232
+
233
+ if __name__ == "__main__":
234
+ main()
@@ -145,6 +145,10 @@ VALID_SIGNALS = {
145
145
  "session_invalid_marker",
146
146
  "captcha_detected",
147
147
  "manual",
148
+ # Deliberate pause from scripts/linkedin_cadence.py (4-active-days /
149
+ # 2-day-break schedule). Not an incident: recover-check must never try to
150
+ # auto-recover it, and engage() should never email on it.
151
+ "scheduled_break",
148
152
  }
149
153
 
150
154
 
@@ -894,6 +898,14 @@ def _cmd_recover_check(args):
894
898
  if not is_active():
895
899
  print("recover-check: killswitch not active, nothing to recover", file=sys.stderr)
896
900
  sys.exit(1)
901
+ payload = read() or {}
902
+ if payload.get("signal") == "scheduled_break":
903
+ print(
904
+ "recover-check: scheduled_break active (cadence pause, not a real "
905
+ "incident); deferring to scripts/linkedin_cadence.py, not probing",
906
+ file=sys.stderr,
907
+ )
908
+ sys.exit(1)
897
909
  if is_terminal():
898
910
  print(
899
911
  "recover-check: TERMINAL (auto-recovery gave up); "
@@ -0,0 +1,20 @@
1
+ #!/bin/bash
2
+ # linkedin-cadence.sh — enforce the 4-active-day / 2-day-break LinkedIn
3
+ # posting schedule (user instruction, 2026-07-11). Fired every 15 minutes by
4
+ # launchd (com.m13v.social-linkedin-cadence). All logic lives in
5
+ # scripts/linkedin_cadence.py; this wrapper just logs the tick.
6
+
7
+ set -uo pipefail
8
+ export PATH="/opt/homebrew/bin:$PATH"
9
+
10
+ REPO_DIR="$HOME/social-autoposter"
11
+ LOG_DIR="$REPO_DIR/skill/logs"
12
+ mkdir -p "$LOG_DIR"
13
+ LOG="$LOG_DIR/linkedin-cadence.log"
14
+
15
+ log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG" >&2; }
16
+
17
+ PY="/opt/homebrew/bin/python3"
18
+ [ -x "$PY" ] || PY="/usr/bin/python3"
19
+
20
+ "$PY" "$REPO_DIR/scripts/linkedin_cadence.py" enforce >>"$LOG" 2>&1
@@ -1725,7 +1725,7 @@ log "Engagement style assigned: mode=$PICKED_MODE style=${PICKED_STYLE:-(invent)
1725
1725
  # are actually different (a same-name pair defeats the whole point); INVENT
1726
1726
  # mode on either side is accepted immediately since an invented name is
1727
1727
  # definitionally distinct from a pinned one. This is orthogonal to the
1728
- # treatment_v2/control_v2 draft-prompt A/B below (that varies WORDING of the
1728
+ # treatment_v3/control_v3 draft-prompt A/B below (that varies WORDING of the
1729
1729
  # directive for the whole cycle; this varies STYLE per draft slot), so neither
1730
1730
  # experiment disturbs the other.
1731
1731
  STYLE_ASSIGN_FILE_B=$(mktemp -t s4l_twitter_assign_b_XXXXXX.json)
@@ -1793,38 +1793,44 @@ fi
1793
1793
  export S4L_EXP_DRAFT_B_SOURCE="$DRAFT_B_SOURCE"
1794
1794
  log "Engagement style B assigned: mode=$PICKED_MODE_B style=${PICKED_STYLE_B:-(invent)} source=$DRAFT_B_SOURCE"
1795
1795
 
1796
- # --- Draft-prompt A/B: decouple product pivot (2026-06-29) -------------------
1796
+ # --- Draft-prompt A/B: style-as-form (v3, 2026-07-10) ------------------------
1797
1797
  # Per-CYCLE arm (the prep session drafts the whole batch from ONE prompt, so
1798
1798
  # assignment is at cycle granularity, not per post; the whole batch shares it).
1799
- # control = the current draft directive verbatim.
1800
- # treatment = v2 (2026-07-06): bans the concede-then-reverse antithesis skeleton
1801
- # ('X is the easy part, the hard part is Y', "not X it's Y", etc.) in
1802
- # ANY form and forces varied entry points. v1 (2026-06-29) only
1803
- # forbade pivoting to the PRODUCT, which the model satisfied while
1804
- # keeping the skeleton (measured: treatment 30% ~= control 28% on
1805
- # 857 local replies), so v2 bans the STRUCTURE, not just the product
1806
- # tail. The SAME skeleton ban is also added to the personal_brand
1807
- # directive below (which overrides both arms), so the persona lane
1808
- # (e.g. customer personal-brand accounts like Karol) gets it too.
1809
- # Product still mentioned only when genuinely relevant.
1799
+ # control_v3 = the plain draft directive (v2's control text verbatim).
1800
+ # treatment_v3 = style-as-FORM package: the assigned engagement style is the
1801
+ # binding FORM of the draft (defining move + per-style length +
1802
+ # end-of-block self-check, rendered arm-aware by
1803
+ # engagement_styles.get_assigned_style_prompt, which reads
1804
+ # S4L_DRAFT_PROMPT_VARIANT at render time IN this cycle
1805
+ # process), PLUS the two-layer learned_preferences contract
1806
+ # (style owns form, preferences own voice inside it; the old
1807
+ # 'overrides on conflict' phrasing made the model treat the
1808
+ # style as optional), PLUS the v2 skeleton ban carried over.
1809
+ # History: v1 (2026-06-29) decoupled the product pivot; the model kept the
1810
+ # skeleton (30% ~= 28% on 857 replies). v2 (2026-07-06) banned the
1811
+ # concede-then-reverse STRUCTURE. v3 (2026-07-10) replaces v2's treatment
1812
+ # wholesale: drafts converged in shape regardless of assigned style, so v3
1813
+ # makes the style block load-bearing instead of only banning one skeleton.
1810
1814
  # The arm is stamped onto every post this cycle via S4L_DRAFT_PROMPT_VARIANT
1811
1815
  # (read by twitter_post_plan.py -> log_post.py -> posts.draft_prompt_variant),
1812
1816
  # mirroring the tail_link_variant plumbing. Split tunable via
1813
1817
  # TWITTER_DRAFT_PROMPT_AB_RATE = fraction of cycles assigned to 'treatment'.
1814
1818
  # CODE DEFAULT 0.5 = 50/50 EVERYWHERE (2026-07-06): every install runs a real
1815
- # holdback so treatment (skeleton-ban, v2) can always be measured against the old
1816
- # control prompt. The old default of 1 (100% treatment) was changed because it
1819
+ # holdback so treatment can always be measured against the plain control
1820
+ # prompt. The old default of 1 (100% treatment) was changed because it
1817
1821
  # silently dropped the control arm whenever the .env pin did not propagate to the
1818
1822
  # running env (the installed-package driver reads its OWN .env, not the source
1819
1823
  # tree's), leaving no control data. Robustly defaulting to 0.5 in code, not via an
1820
1824
  # .env override, prevents that. The dashboard reads the SAME var with the SAME
1821
1825
  # default (bin/server.js), so display and routing never diverge.
1822
1826
  DRAFT_PROMPT_AB_RATE="${TWITTER_DRAFT_PROMPT_AB_RATE:-0.5}"
1823
- # Arm VALUE versioned to '..._v2' on 2026-07-06 to RESET the experiment. The old
1824
- # 'treatment'/'control' rows (v1, decoupled-product-pivot) are retired: they stay
1825
- # in the DB under their old labels but the dashboard now counts only the '_v2'
1826
- # arms, so the v2 skeleton-ban experiment starts fresh from zero. Bump this suffix
1827
- # again on any future reset (keep bin/server.js DRAFT_PROMPT_VARIANT_DEFS in sync).
1827
+ # Arm VALUE versioned to '..._v3' on 2026-07-10 to RESET the experiment. The old
1828
+ # v1/v2 rows stay in the DB under their old labels but the dashboard now counts
1829
+ # only the '_v3' arms, so the style-as-form experiment starts fresh from zero.
1830
+ # Bump this suffix again on any future reset (keep bin/server.js
1831
+ # DRAFT_PROMPT_VARIANT_DEFS, scripts/active_experiments.py DESCRIPTIONS, and the
1832
+ # treatment_v3 gate in scripts/engagement_styles.py get_assigned_style_prompt in
1833
+ # sync).
1828
1834
  S4L_DRAFT_PROMPT_VARIANT=$(python3 -c "
1829
1835
  import random
1830
1836
  try:
@@ -1832,12 +1838,12 @@ try:
1832
1838
  except Exception:
1833
1839
  rate = 0.5
1834
1840
  rate = min(1.0, max(0.0, rate))
1835
- print('treatment_v2' if random.random() < rate else 'control_v2')
1836
- " 2>/dev/null || echo treatment_v2)
1841
+ print('treatment_v3' if random.random() < rate else 'control_v3')
1842
+ " 2>/dev/null || echo treatment_v3)
1837
1843
  export S4L_DRAFT_PROMPT_VARIANT
1838
1844
  log "Draft-prompt A/B arm: $S4L_DRAFT_PROMPT_VARIANT (rate=$DRAFT_PROMPT_AB_RATE)"
1839
- if [ "$S4L_DRAFT_PROMPT_VARIANT" = "treatment_v2" ]; then
1840
- DRAFT_DIRECTIVE="Otherwise: draft a direct, natural reply that stands on its own as a useful contribution to the thread. Mention the matched project only when it is genuinely the most relevant thing to say, and state it plainly in one clause; most replies will not need it. Do NOT use the concede-then-reverse skeleton in ANY form. Banned openings include: 'X is the easy part/half/win, the hard part is Y'; 'X was never the [thing], it's Y'; 'X isn't the [problem], it's Y'; 'the real/actual/harder part is Y'; 'what actually breaks/ships/matters is Y'; 'the part nobody says/shows is Y'; 'X is solved, Y is what breaks'. If your draft contains that concede-then-reverse pivot, rewrite it from a different entry point. This rule OVERRIDES the assigned style's example when that example uses the skeleton: keep the style's intent, not its shape. Instead lead with substance from ONE entry point, and vary the entry point across replies: a concrete first-hand specific or number; a direct answer to the exact question asked; one sharp opinion with no hedge; a genuine question that moves the thread forward; or a relevant pointer. No warm-up framing sentence before the substance. Length is governed ENTIRELY by the per-style LENGTH LIMIT in the style block above; obey that target and ceiling, do not apply any other length rule here. NEVER em dashes. Apply the matched project's \`voice\` block from ALL_PROJECTS_JSON: follow voice.tone, never violate voice.never, mirror voice.examples / voice.examples_good when present. The matched project's learned_preferences block in ALL_PROJECTS_JSON is distilled human review feedback and is MANDATORY, not advisory: follow every learned_preferences.draft_style_notes entry when writing (it overrides the engagement style's structural template on conflict), and treat learned_preferences.audience_avoid / thread_avoid matches as strong reasons to skip the candidate. Never violate content_guardrails.do_not."
1845
+ if [ "$S4L_DRAFT_PROMPT_VARIANT" = "treatment_v3" ]; then
1846
+ DRAFT_DIRECTIVE="Otherwise: draft a direct, natural reply that stands on its own as a useful contribution to the thread. Mention the matched project only when it is genuinely the most relevant thing to say, and state it plainly in one clause; most replies will not need it. THE ASSIGNED ENGAGEMENT STYLE IS THE FORM OF THIS DRAFT, not a flavor hint: the style block above defines the draft's structure, defining move, and length. Commit to that form BEFORE writing, and run the style block's self-check before returning the draft. Do NOT use the concede-then-reverse skeleton in ANY form. Banned openings include: 'X is the easy part/half/win, the hard part is Y'; 'X was never the [thing], it's Y'; 'X isn't the [problem], it's Y'; 'the real/actual/harder part is Y'; 'what actually breaks/ships/matters is Y'; 'the part nobody says/shows is Y'; 'X is solved, Y is what breaks'. If your draft contains that concede-then-reverse pivot, rewrite it from a different entry point. This ban OVERRIDES the assigned style's example when that example uses the skeleton: keep the style's defining move, express it without the skeleton. Lead with substance from ONE entry point and vary the entry point across replies: a concrete first-hand specific or number; a direct answer to the exact question asked; one sharp opinion with no hedge; a genuine question that moves the thread forward; or a relevant pointer. No warm-up framing sentence before the substance. Length is governed ENTIRELY by the per-style LENGTH LIMIT in the style block above; obey that target and ceiling, do not apply any other length rule here. NEVER em dashes. Apply the matched project's \`voice\` block from ALL_PROJECTS_JSON: follow voice.tone, never violate voice.never, mirror voice.examples / voice.examples_good when present. The matched project's learned_preferences block in ALL_PROJECTS_JSON is distilled human review feedback and is MANDATORY, not advisory, and it works TOGETHER with the engagement style on different layers: the style owns the FORM (structure, defining move, length) and learned_preferences.draft_style_notes own the voice, wording, and content choices INSIDE that form. When a preference seems to conflict with the style, keep the style's structure and satisfy the preference within it; never drop the style's defining move to satisfy a wording note. Treat learned_preferences.audience_avoid / thread_avoid matches as strong reasons to skip the candidate. Never violate content_guardrails.do_not."
1841
1847
  else
1842
1848
  DRAFT_DIRECTIVE="Otherwise: draft a reply using the best engagement style. Length is governed ENTIRELY by the per-style LENGTH LIMIT in the style block above; obey that target and ceiling, do not apply any other length rule here. NEVER em dashes. Apply the matched project's \`voice\` block from ALL_PROJECTS_JSON: follow voice.tone, never violate voice.never, mirror voice.examples / voice.examples_good when present. The matched project's learned_preferences block in ALL_PROJECTS_JSON is distilled human review feedback and is MANDATORY, not advisory: follow every learned_preferences.draft_style_notes entry when writing (it overrides the engagement style's structural template on conflict), and treat learned_preferences.audience_avoid / thread_avoid matches as strong reasons to skip the candidate. Never violate content_guardrails.do_not."
1843
1849
  fi
@@ -1846,20 +1852,28 @@ fi
1846
1852
  # growth: no product, no link, no CTA. The reply must add real value grounded in
1847
1853
  # the persona's first-hand material (the PERSONA CORPUS block + the persona voice
1848
1854
  # block), not concede-and-agree filler. Replaces the product-framed promotion
1849
- # directives above, but is itself arm-aware (see below): treatment_v2 adds the
1850
- # skeleton ban, control_v2 does not, so the A/B runs in this lane too.
1855
+ # directives above, but is itself arm-aware (see below): treatment_v3 adds the
1856
+ # skeleton ban + the two-layer style/preferences contract, control_v3 keeps the
1857
+ # plain persona directive, so the A/B runs in this lane too.
1851
1858
  if [ "${S4L_ACTIVE_LANE:-}" = "personal_brand" ]; then
1852
- # Arm-aware skeleton ban in the persona lane (2026-07-06): treatment_v2 adds the
1853
- # concede-then-reverse ban clause; control_v2 keeps the plain persona directive.
1854
- # This makes the A/B measurable in the persona lane too. The arm is stamped onto
1855
- # the card + surfaced (active_experiments.py no longer drops draft_prompt for
1856
- # personal_brand), so persona cards now show treatment_v2 / control_v2.
1857
- if [ "$S4L_DRAFT_PROMPT_VARIANT" = "treatment_v2" ]; then
1858
- PERSONA_SKELETON_BAN=" Also do NOT use the concede-then-reverse skeleton in ANY form: banned openings include 'X is the easy part/half/win, the hard part is Y', 'X was never the [thing], it's Y', 'X isn't the [problem], it's Y', 'the real/actual/harder part is Y', 'what actually breaks/ships/matters is Y', 'the part nobody says/shows is Y', and 'X is solved, Y is what breaks'; if a draft has that pivot, rewrite it from one of the entry points above. This OVERRIDES the assigned style's example when that example uses the skeleton: keep the style's intent, not its shape."
1859
+ # Arm-aware persona lane (v3, 2026-07-10): treatment_v3 adds the
1860
+ # concede-then-reverse ban clause AND swaps the learned_preferences
1861
+ # relation from 'overrides the style on conflict' to the two-layer
1862
+ # contract (style owns form, preferences own voice inside it);
1863
+ # control_v3 keeps the plain persona directive. The style block itself
1864
+ # (STYLES_BLOCK, rendered in this process) is also arm-aware, so the
1865
+ # persona lane gets the style-as-FORM block on treatment automatically.
1866
+ # The arm is stamped onto the card + surfaced (active_experiments.py no
1867
+ # longer drops draft_prompt for personal_brand), so persona cards show
1868
+ # treatment_v3 / control_v3.
1869
+ if [ "$S4L_DRAFT_PROMPT_VARIANT" = "treatment_v3" ]; then
1870
+ PERSONA_SKELETON_BAN=" Also do NOT use the concede-then-reverse skeleton in ANY form: banned openings include 'X is the easy part/half/win, the hard part is Y', 'X was never the [thing], it's Y', 'X isn't the [problem], it's Y', 'the real/actual/harder part is Y', 'what actually breaks/ships/matters is Y', 'the part nobody says/shows is Y', and 'X is solved, Y is what breaks'; if a draft has that pivot, rewrite it from one of the entry points above. This ban OVERRIDES the assigned style's example when that example uses the skeleton: keep the style's defining move, express it without the skeleton."
1871
+ PERSONA_PREFS_RELATION="(learned_preferences work TOGETHER with the engagement style on different layers: the style owns the FORM, structure, defining move, and length; draft_style_notes own the voice and wording INSIDE that form; on apparent conflict keep the style's structure and satisfy the preference within it)"
1859
1872
  else
1860
1873
  PERSONA_SKELETON_BAN=""
1874
+ PERSONA_PREFS_RELATION="(it overrides the engagement style's structural template on conflict)"
1861
1875
  fi
1862
- DRAFT_DIRECTIVE="Otherwise: draft a reply that stands on its own as a genuinely useful contribution to THIS thread. Ground it in the persona's real, first-hand experience from the PERSONA CORPUS block below (specific projects, real numbers, sharp opinions, actual failures) and in the persona's \`voice\` block from ALL_PROJECTS_JSON. Add exactly ONE of: a concrete specific from that lived experience, a sharp non-obvious opinion, a useful pointer, or a question that genuinely moves the thread forward. NEVER generic agreement ('makes sense', 'this is spot on', 'great point', 'the nuance here is').${PERSONA_SKELETON_BAN} This is a personal account, not a brand: sound like a real person in the thread. If web search is available and the thread hinges on a current fact, verify it before drafting rather than guessing. Length is governed ENTIRELY by the per-style LENGTH LIMIT in the style block above; obey that target and ceiling. NEVER em dashes. Follow voice.tone, never violate voice.never, mirror voice.examples / voice.examples_good when present. The persona's learned_preferences block in ALL_PROJECTS_JSON is distilled human review feedback and is MANDATORY, not advisory: follow every learned_preferences.draft_style_notes entry when writing (it overrides the engagement style's structural template on conflict), and treat learned_preferences.audience_avoid / thread_avoid matches as strong reasons to skip the candidate. Never violate content_guardrails.do_not."
1876
+ DRAFT_DIRECTIVE="Otherwise: draft a reply that stands on its own as a genuinely useful contribution to THIS thread. Ground it in the persona's real, first-hand experience from the PERSONA CORPUS block below (specific projects, real numbers, sharp opinions, actual failures) and in the persona's \`voice\` block from ALL_PROJECTS_JSON. Add exactly ONE of: a concrete specific from that lived experience, a sharp non-obvious opinion, a useful pointer, or a question that genuinely moves the thread forward. NEVER generic agreement ('makes sense', 'this is spot on', 'great point', 'the nuance here is').${PERSONA_SKELETON_BAN} This is a personal account, not a brand: sound like a real person in the thread. If web search is available and the thread hinges on a current fact, verify it before drafting rather than guessing. Length is governed ENTIRELY by the per-style LENGTH LIMIT in the style block above; obey that target and ceiling. NEVER em dashes. Follow voice.tone, never violate voice.never, mirror voice.examples / voice.examples_good when present. The persona's learned_preferences block in ALL_PROJECTS_JSON is distilled human review feedback and is MANDATORY, not advisory: follow every learned_preferences.draft_style_notes entry when writing ${PERSONA_PREFS_RELATION}, and treat learned_preferences.audience_avoid / thread_avoid matches as strong reasons to skip the candidate. Never violate content_guardrails.do_not."
1863
1877
  fi
1864
1878
 
1865
1879
  # 2026-07-10 anti-sameness: --no-project-sections strips the multi-project
@@ -2194,6 +2208,27 @@ CRITICAL:
2194
2208
  # installs) treats --allowedTools as a one-value flag, so a space-separated second
2195
2209
  # tool would leak in as the prompt. On the box these flags ride through
2196
2210
  # claude_job.py; Desktop's own web search + the reworded prompt enable it there.
2211
+ # --- Prep-prompt snapshot (2026-07-11) ---------------------------------------
2212
+ # Persist the exact rendered PREP_PROMPT per batch so prompt-block presence is
2213
+ # verifiable after any release (grep the file), instead of reverse-engineering
2214
+ # it from package scripts. The queue's prompt-*.md files are transient work
2215
+ # files deleted on completion, and the generation trace deliberately carries
2216
+ # only the exemplar context, so this is the ONLY durable full-prompt record.
2217
+ # Local-only, newest 50 kept (file cleanup, not candidate-row retention; the
2218
+ # no-retention rule covers DB *_candidates rows). Never blocks the run.
2219
+ PREP_PROMPT_DIR="${S4L_STATE_DIR:-$HOME/.social-autoposter-mcp}/prep-prompts"
2220
+ if mkdir -p "$PREP_PROMPT_DIR" 2>/dev/null; then
2221
+ _PP_FILE="$PREP_PROMPT_DIR/prep-prompt-$BATCH_ID.md"
2222
+ if printf '%s' "$PREP_PROMPT" > "$_PP_FILE" 2>/dev/null; then
2223
+ ls -t "$PREP_PROMPT_DIR"/prep-prompt-*.md 2>/dev/null | tail -n +51 | while IFS= read -r _pp_old; do
2224
+ rm -f "$_pp_old"
2225
+ done
2226
+ log "[prep_prompt_snapshot] batch=$BATCH_ID bytes=$(wc -c < "$_PP_FILE" | tr -d ' ') path=$_PP_FILE"
2227
+ else
2228
+ log "WARN: prep-prompt snapshot write failed for batch=$BATCH_ID (non-fatal)"
2229
+ fi
2230
+ fi
2231
+
2197
2232
  PREP_OUTPUT=$(printf '%s' "$PREP_PROMPT" | "$REPO_DIR/scripts/run_claude.sh" "run-twitter-cycle-prep" --strict-mcp-config --mcp-config "$TW_MCP_CONFIG" --allowedTools WebSearch,WebFetch -p --output-format json --json-schema "$PREP_SCHEMA" 2>&1)
2198
2233
 
2199
2234
  echo "$PREP_OUTPUT" >> "$LOG_FILE"