@m13v/s4l 1.7.2-rc.10 → 1.7.2-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/mcp/dist/index.js CHANGED
@@ -2244,17 +2244,7 @@ tool("post_drafts", {
2244
2244
  return;
2245
2245
  }
2246
2246
  c.terminal = true;
2247
- // Preserve a more specific reason the menu bar already stamped locally
2248
- // (store_stamp_decision writes "human_rejected" for a card reject,
2249
- // discard_all_pending writes "human_discarded_all" for the bulk-discard
2250
- // button) BEFORE this loopback call ever fires, so by the time it lands
2251
- // here the local reason is already the more informative one. Only
2252
- // default to "rejected" when nothing more specific got there first
2253
- // (e.g. a reject driven straight from chat, with no menu-bar card
2254
- // involved at all).
2255
- if (!c.terminal_reason) {
2256
- c.terminal_reason = "rejected";
2257
- }
2247
+ c.terminal_reason = "rejected";
2258
2248
  c.approved = false;
2259
2249
  rejected.push(n);
2260
2250
  });
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "1.7.2-rc.10",
3
- "installedAt": "2026-07-09T05:38:51.644Z"
2
+ "version": "1.7.2-rc.2",
3
+ "installedAt": "2026-07-09T01:12:07.771Z"
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.2-rc.10",
5
+ "version": "1.7.2-rc.2",
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": {
@@ -1600,37 +1600,6 @@ class _ReviewController(NSObject):
1600
1600
  self._log_surface(f"extended +{len(added)}")
1601
1601
  return len(added)
1602
1602
 
1603
- @objc.python_method
1604
- def prune_drafts(self, ns):
1605
- """Drop not-yet-reached drafts (plan index `n` in `ns`) from the stack,
1606
- e.g. a card the backend already retired (expired freshness gate, etc.)
1607
- while it was still waiting to be shown. Only ever removes entries AFTER
1608
- the current index, so the card on screen right now and everything
1609
- already decided are untouched -- nothing visible disappears out from
1610
- under the user. Refreshes the title-bar counter live. Returns the
1611
- count actually removed."""
1612
- if self._panel is None or not ns:
1613
- return 0
1614
- ns = set(ns)
1615
- kept = []
1616
- removed = 0
1617
- for i, d in enumerate(self._drafts):
1618
- if i > self._idx and d.get("n") in ns:
1619
- removed += 1
1620
- continue
1621
- kept.append(d)
1622
- if not removed:
1623
- return 0
1624
- self._drafts = kept
1625
- try:
1626
- self._panel.setTitle_(
1627
- f"s4l · Review draft {self._idx + 1} of {len(self._drafts)}"
1628
- )
1629
- except Exception:
1630
- pass
1631
- self._log_surface(f"pruned {removed} expired")
1632
- return removed
1633
-
1634
1603
  @objc.python_method
1635
1604
  def _fire_decision(self):
1636
1605
  # Fire the per-card callback the instant a decision is made, so an
@@ -1863,22 +1832,6 @@ def extend_active(drafts):
1863
1832
  return 0
1864
1833
 
1865
1834
 
1866
- def prune_active(ns):
1867
- """Remove not-yet-reached drafts (by plan index `n`) from the open review
1868
- card, if one is up -- e.g. a card that expired on the backend mid-review
1869
- (see merge_review_queue.py's backend sync). Never touches the card
1870
- currently on screen or any already-decided one, so nothing visible is
1871
- yanked out from under the user. Returns the count actually removed (0 if
1872
- no card is open or none of `ns` are still ahead in the stack). Main thread
1873
- only (called from the menu bar's rumps timer)."""
1874
- if _active is None or not ns:
1875
- return 0
1876
- try:
1877
- return _active.prune_drafts(ns)
1878
- except Exception:
1879
- return 0
1880
-
1881
-
1882
1835
  def active_status():
1883
1836
  """Live review-surface snapshot for the menu bar's unattended-review
1884
1837
  watchdog, or None when no card is open. Main thread only."""
@@ -1891,36 +1844,6 @@ def active_status():
1891
1844
  return None
1892
1845
 
1893
1846
 
1894
- def dismiss_active():
1895
- """Force-close the open review panel WITHOUT firing on_decision/on_complete,
1896
- for a bulk discard whose fate for every remaining card was already decided
1897
- elsewhere (the menu bar's store-level "Discard all pending drafts"). A
1898
- normal windowShouldClose_ close still fires on_complete (leftover cards are
1899
- just undecided); this path skips both callbacks entirely so the bulk
1900
- discard's own bookkeeping is the only thing that runs. Returns True if a
1901
- panel was actually open."""
1902
- global _active
1903
- c = _active
1904
- if c is None or c._panel is None:
1905
- return False
1906
- try:
1907
- c._close_stats_popover()
1908
- except Exception:
1909
- pass
1910
- try:
1911
- c._panel.setDelegate_(None)
1912
- c._panel.close()
1913
- except Exception:
1914
- pass
1915
- c._panel = None
1916
- c._on_complete = None
1917
- c._on_decision = None
1918
- _active = None
1919
- _log(f"closed: dismissed (bulk discard, {len(c._decisions)} decided of {len(c._drafts)})")
1920
- _write_review_state(last_event="dismissed")
1921
- return True
1922
-
1923
-
1924
1847
  def heal_active():
1925
1848
  """Self-heal an unattended card: move it to the top-right of the screen the
1926
1849
  pointer is on and raise it, WITHOUT stealing keyboard focus (the user is
@@ -785,12 +785,19 @@ class S4LMenuBar(rumps.App):
785
785
  relaunch. Primary action for this case now instead of re-arm: no
786
786
  clipboard paste, no chat turn required. Re-arm remains the fallback
787
787
  for the rarer case where no session directory exists yet for the
788
- active account (see _build_menu) — fix 5 never fabricates one.
789
-
790
- No confirm dialog: the restart is already disclosed in the menu item
791
- label itself ("...(restarts Claude)", _build_menu) — a modal repeating
792
- that would just be a second click for information the user already
793
- has (2026-07-09). The notify toast below still tells them it started."""
788
+ active account (see _build_menu) — fix 5 never fabricates one."""
789
+ _activate_front()
790
+ choice = _show_alert(
791
+ title="Finish setting up drafts?",
792
+ message=(
793
+ "Claude needs to restart once to finish registering your draft "
794
+ "schedule for this account. Its window will close and reopen "
795
+ "in a moment — drafting starts within a few minutes after."
796
+ ),
797
+ ok="Restart & Finish Setup", cancel="Cancel",
798
+ )
799
+ if choice != 1:
800
+ return
794
801
  _capture_msg(
795
802
  "S4L finish-schedule-setup clicked",
796
803
  phase="draft_schedule",
@@ -1140,7 +1147,7 @@ class S4LMenuBar(rumps.App):
1140
1147
  "Uninstall: keep your X login + browser layer (quick uninstall).\n"
1141
1148
  "Deep wipe: also remove the shared browser profiles + toolchain."
1142
1149
  ),
1143
- ok="Uninstall & Restart Claude", cancel="Cancel", other="Deep wipe & Restart Claude",
1150
+ ok="Uninstall & Restart Claude", cancel="Cancel", other="Deep wipe",
1144
1151
  )
1145
1152
  if choice == 0: # cancel
1146
1153
  return
@@ -2502,11 +2509,6 @@ class S4LMenuBar(rumps.App):
2502
2509
  if ob
2503
2510
  else 0
2504
2511
  )
2505
- # Pending draft-card count, for the bulk-discard menu item (visibility +
2506
- # label). Cheap local JSON reads, same source _maybe_start_review uses.
2507
- _, pending_drafts = self._pending_review()
2508
- pending_count = len(pending_drafts)
2509
-
2510
2512
  # _update_available / _latest_version are in the signature so a freshly
2511
2513
  # detected update rebuilds the menu (adding "Update now & restart Claude Desktop") even mid-run.
2512
2514
  sig = (
@@ -2528,14 +2530,10 @@ class S4LMenuBar(rumps.App):
2528
2530
  schedule_state,
2529
2531
  self._stall_reason_info,
2530
2532
  os.path.exists(PAUSE_FLAG),
2531
- pending_count,
2532
2533
  )
2533
2534
  if sig != self._sig:
2534
2535
  self._sig = sig
2535
- self._build_menu(
2536
- runtime_ready, setup_complete, ob, blocker, snap, attention, schedule_state,
2537
- pending_count=pending_count,
2538
- )
2536
+ self._build_menu(runtime_ready, setup_complete, ob, blocker, snap, attention, schedule_state)
2539
2537
 
2540
2538
  # Draft-review pop-ups: if a draft cycle left a review request, present the
2541
2539
  # cards. Don't start a review mid-run (the spinner means a tool is active).
@@ -2587,21 +2585,6 @@ class S4LMenuBar(rumps.App):
2587
2585
  self._posting_batch_total = 0
2588
2586
  self._posting_batch_done = 0
2589
2587
 
2590
- def _pending_review(self):
2591
- """(batch_id, undecided draft cards) from the durable review store —
2592
- the same source _maybe_start_review presents from. Cheap (small local
2593
- JSON reads); drives the menu's bulk-discard item, both its visibility
2594
- and what it acts on."""
2595
- try:
2596
- req = st.read_review_request()
2597
- batch = (req or {}).get("batch_id")
2598
- if not batch:
2599
- return None, []
2600
- plan = st.read_plan(req.get("plan_path") or "")
2601
- return batch, st.review_drafts(plan)
2602
- except Exception:
2603
- return None, []
2604
-
2605
2588
  def _maybe_start_review(self):
2606
2589
  req = st.read_review_request()
2607
2590
  if not req:
@@ -2632,13 +2615,6 @@ class S4LMenuBar(rumps.App):
2632
2615
  # live. This is the fix for the "card froze at 1 of 4 while 137 piled
2633
2616
  # up" bug — drafts that arrived after the card opened used to be
2634
2617
  # stranded because this method returned early on _review_active.
2635
- # Also prune the other direction: any `n` this same stack used to
2636
- # carry but that dropped out of the fresh `drafts` list (merge_review_
2637
- # queue.py's backend sync just marked it terminal, most commonly the
2638
- # freshness gate expiring it) is removed from the not-yet-reached part
2639
- # of the stack, so an old card can't sit there waiting to be approved
2640
- # into a silent no-op (see the 2026-07-09 "approved 3, nothing
2641
- # posted" investigation).
2642
2618
  # - Posting is DRAINING with no panel up (_review_active but not
2643
2619
  # _panel_open): leave the signature untouched so the full pending set
2644
2620
  # is presented fresh once the drain completes (don't pop a card mid-post).
@@ -2647,11 +2623,6 @@ class S4LMenuBar(rumps.App):
2647
2623
  try:
2648
2624
  import s4l_card
2649
2625
 
2650
- prev_ns = {n for n, _ in (self._last_review_sig or ())}
2651
- cur_ns = {d.get("n") for d in drafts}
2652
- vanished = prev_ns - cur_ns
2653
- if vanished:
2654
- s4l_card.prune_active(vanished)
2655
2626
  s4l_card.extend_active(drafts)
2656
2627
  except Exception as e:
2657
2628
  sys.stderr.write(f"[s4l-menubar] extend cards failed: {e}\n")
@@ -2910,66 +2881,6 @@ class S4LMenuBar(rumps.App):
2910
2881
  if not any(d.get("approved") for d in decisions):
2911
2882
  self._notify("S4L", "No drafts approved — nothing posted.")
2912
2883
 
2913
- def _discard_all_pending(self, _=None):
2914
- """Bulk-discard every pending draft card: rejected with NO reason,
2915
- instantly, and — deliberately — without shipping a review event for any
2916
- of them. A normal per-card reject always ships one (feeding the
2917
- feedback-digest -> learned_preferences rail, see _ship_review_event);
2918
- this bulk "clear the backlog" action carries no per-card judgment
2919
- signal, so it must never reach that rail (user-specified 2026-07-08)."""
2920
- batch, drafts = self._pending_review()
2921
- if not batch or not drafts:
2922
- return
2923
- n = len(drafts)
2924
- choice = _show_alert(
2925
- title="Discard all pending drafts?",
2926
- message=(
2927
- f"Discards all {n} pending draft(s) right now, with no reason "
2928
- "given. None of them will post or be shown for review again. "
2929
- "This does not feed the AI feedback loop (learned_preferences) "
2930
- "the way an individual reject does."
2931
- ),
2932
- ok="Discard All", cancel="Cancel",
2933
- )
2934
- if choice != 1:
2935
- return
2936
- try:
2937
- st.discard_all_pending(drafts)
2938
- except Exception as e:
2939
- sys.stderr.write(f"[s4l-menubar] discard all pending failed: {e}\n")
2940
- sys.stderr.flush()
2941
- _capture(e, phase="discard_all_pending")
2942
- self._alert("Discard failed", str(e)[:200])
2943
- return
2944
- ns = [d.get("n") for d in drafts if d.get("n") is not None]
2945
- cids = [d.get("candidate_id") for d in drafts if d.get("candidate_id") is not None]
2946
-
2947
- def _persist_discard():
2948
- try:
2949
- st.post_drafts(batch, reject=ns, timeout=60)
2950
- except Exception:
2951
- pass
2952
- try:
2953
- st.flip_discarded_candidates_skipped(cids)
2954
- except Exception:
2955
- pass
2956
-
2957
- threading.Thread(target=_persist_discard, daemon=True).start()
2958
- try:
2959
- import s4l_card
2960
-
2961
- s4l_card.dismiss_active()
2962
- except Exception:
2963
- pass
2964
- with self._review_lock:
2965
- self._panel_open = False
2966
- if self._posts_outstanding <= 0:
2967
- self._review_active = False
2968
- self._reset_posting_progress_locked()
2969
- self._last_review_sig = None
2970
- st.clear_review_request()
2971
- self._notify("S4L", f"Discarded {n} pending draft(s).")
2972
-
2973
2884
  def _ensure_post_worker(self):
2974
2885
  # One persistent daemon worker drains the approved-card queue. It never
2975
2886
  # exits (avoids an enqueue-vs-exit race) — an idle parked thread is cheap.
@@ -3075,7 +2986,7 @@ class S4LMenuBar(rumps.App):
3075
2986
  self.title = "S4L"
3076
2987
 
3077
2988
  # ---- menu construction ------------------------------------------------
3078
- def _build_menu(self, runtime_ready, setup_complete, ob, blocker, snap, attention=False, schedule_state="ok", pending_count=0):
2989
+ def _build_menu(self, runtime_ready, setup_complete, ob, blocker, snap, attention=False, schedule_state="ok"):
3079
2990
  self.menu.clear()
3080
2991
  items = []
3081
2992
 
@@ -3097,6 +3008,7 @@ class S4LMenuBar(rumps.App):
3097
3008
  items.append(rumps.MenuItem("Resume S4L", callback=self._pause_toggle))
3098
3009
  else:
3099
3010
  items.append(rumps.MenuItem("Pause S4L", callback=self._pause_toggle))
3011
+ items.append(self._label(" stop drafting/posting, keep Claude + tray running"))
3100
3012
  items.append(rumps.separator)
3101
3013
 
3102
3014
  # Attention = the draft schedule isn't running for THIS account (missing or
@@ -3190,7 +3102,7 @@ class S4LMenuBar(rumps.App):
3190
3102
  elif not setup_complete:
3191
3103
  items += self._state_b(ob, blocker)
3192
3104
  else:
3193
- items += self._state_c(snap, pending_count)
3105
+ items += self._state_c(snap)
3194
3106
 
3195
3107
  # Engagement lanes — ALWAYS visible (every state), not just post-setup, so
3196
3108
  # the user can see + flip either lane any time. Two INDEPENDENT checkmarks
@@ -3200,15 +3112,19 @@ class S4LMenuBar(rumps.App):
3200
3112
  personal_on = bool(flags.get("personal_brand"))
3201
3113
  promo_on = bool(flags.get("promotion"))
3202
3114
  items.append(rumps.separator)
3115
+ items.append(self._label("Engagement lanes"))
3203
3116
  pb_item = rumps.MenuItem("Personal brand", callback=self._toggle_personal)
3204
3117
  pb_item.state = 1 if personal_on else 0
3205
3118
  items.append(pb_item)
3119
+ items.append(self._label(" organic, link-free engagement"))
3206
3120
  pr_item = rumps.MenuItem("Product promotion", callback=self._toggle_promotion)
3207
3121
  pr_item.state = 1 if promo_on else 0
3208
3122
  items.append(pr_item)
3123
+ items.append(self._label(" promoting your products (link replies)"))
3209
3124
  if personal_on and promo_on:
3210
3125
  # Both lanes on: the split becomes meaningful, so offer the presets.
3211
3126
  share = st.read_split()
3127
+ items.append(self._label(f" both on · cycles split {self._split_pct(share)}"))
3212
3128
  split_menu = rumps.MenuItem(f"Lane split: {self._split_pct(share)}")
3213
3129
  for preset in self.SPLIT_PRESETS:
3214
3130
  it = rumps.MenuItem(
@@ -3225,6 +3141,7 @@ class S4LMenuBar(rumps.App):
3225
3141
  # carries a Feedback button); named for what it does to the pipeline,
3226
3142
  # not the mechanism.
3227
3143
  items.append(rumps.MenuItem("Give overall feedback to AI…", callback=self._menu_feedback))
3144
+ items.append(self._label(" overall guidance, steers future drafts"))
3228
3145
  # While the update-verify marker is pending, the pipeline copy still
3229
3146
  # resolves the OLD version (it only advances once the restarted server
3230
3147
  # re-provisions repo/package, ~2 min), so the snapshot honestly reports
@@ -3251,10 +3168,8 @@ class S4LMenuBar(rumps.App):
3251
3168
  items.append(rumps.separator)
3252
3169
  items.append(rumps.MenuItem("Tidy autopilot history…", callback=self._prompt_relocate_tasks))
3253
3170
  items.append(rumps.separator)
3254
- quit_menu = rumps.MenuItem("Quit or Uninstall")
3255
- quit_menu.add(rumps.MenuItem("Uninstall S4L…", callback=self._reset_machine))
3256
- quit_menu.add(rumps.MenuItem("Quit", callback=self._quit_app))
3257
- items.append(quit_menu)
3171
+ items.append(rumps.MenuItem("Uninstall S4L…", callback=self._reset_machine))
3172
+ items.append(rumps.MenuItem("Quit", callback=self._quit_app))
3258
3173
 
3259
3174
  # Collapse consecutive/edge separators so an empty section (e.g. State C
3260
3175
  # now renders no status labels) can't leave a doubled or dangling divider.
@@ -3338,15 +3253,8 @@ class S4LMenuBar(rumps.App):
3338
3253
  # The engagement-mode toggles live in _build_menu (shown in EVERY state), and
3339
3254
  # there is deliberately no "Run draft cycle" / "Post approved drafts" item
3340
3255
  # (the autopilot drafts on its own; approving a review card already posts it).
3341
- def _state_c(self, snap, pending_count=0):
3342
- if pending_count <= 0:
3343
- return []
3344
- return [
3345
- rumps.MenuItem(
3346
- f"Discard {pending_count} pending draft{'s' if pending_count != 1 else ''}…",
3347
- callback=self._discard_all_pending,
3348
- )
3349
- ]
3256
+ def _state_c(self, snap):
3257
+ return []
3350
3258
 
3351
3259
 
3352
3260
  if __name__ == "__main__":
@@ -770,78 +770,6 @@ def store_stamp_decision(batch, decision):
770
770
  return bool(_store_update(mutate))
771
771
 
772
772
 
773
- def discard_all_pending(drafts):
774
- """Bulk-reject an entire pending set (the menu bar's "Discard all pending
775
- drafts"), durably and atomically in ONE lock acquisition. Each candidate is
776
- stamped terminal with NO reason (reject_category=None), same as a card's
777
- "Reject, no reason" button. Deliberately distinct from store_stamp_decision:
778
- this never ships a review event, by design, so a bulk "clear the backlog"
779
- click never reaches the review-events/feedback-digest rail and can't
780
- pollute learned_preferences with a non-judgment. Returns how many were
781
- stamped."""
782
-
783
- def mutate(data):
784
- done = 0
785
- for d in drafts:
786
- c = _match_candidate(data, d.get("n"), d.get("candidate_id"))
787
- if c is None or c.get("posted") is True or c.get("terminal") is True:
788
- continue
789
- c["terminal"] = True
790
- c["terminal_reason"] = "human_discarded_all"
791
- c["decision"] = {
792
- "approved": False,
793
- "text": c.get("reply_text") or "",
794
- "edited": False,
795
- "drop_link": False,
796
- "loved": False,
797
- "reject_category": None,
798
- "decided_at": time_iso(),
799
- }
800
- done += 1
801
- return done
802
-
803
- return _store_update(mutate) or 0
804
-
805
-
806
- def flip_discarded_candidates_skipped(candidate_ids):
807
- """Flip each bulk-discarded candidate's twitter_candidates row to
808
- status='skipped', skip_reason='human_discarded_all' — the same DB effect a
809
- normal reject gets for free as a side effect of its review-events insert
810
- (see review-events/route.ts). The bulk-discard path deliberately never
811
- ships a review event (that's the whole point — it must not reach the
812
- feedback digest), so without this direct call the row would stay
813
- 'pending' until the independent age-based freshness gate happens to expire
814
- it, wide open to re-discovery/re-drafting of the exact draft a human just
815
- discarded. One PATCH per candidate via the SAME direct-HTTP path
816
- flush_review_events already uses (http_api), so this never touches
817
- review_events. Best-effort: a candidate that already expired/posted by the
818
- time this runs (404, no longer 'pending') is a no-op, not a failure."""
819
- if not candidate_ids:
820
- return
821
- try:
822
- from http_api import api_patch
823
- except Exception as err:
824
- sys.stderr.write(
825
- f"[s4l-state] flip_discarded_candidates_skipped: http_api unavailable "
826
- f"({type(err).__name__}: {err}); {len(candidate_ids)} candidate(s) left 'pending'\n"
827
- )
828
- sys.stderr.flush()
829
- return
830
- for cid in candidate_ids:
831
- try:
832
- api_patch(
833
- "/api/v1/twitter-candidates/by-id",
834
- {"id": cid, "action": "mark_skipped", "reason": "human_discarded_all"},
835
- ok_on_404=True,
836
- )
837
- except Exception as err:
838
- sys.stderr.write(
839
- f"[s4l-state] flip_discarded_candidates_skipped: PATCH failed for "
840
- f"candidate_id={cid} ({type(err).__name__}: {err})\n"
841
- )
842
- sys.stderr.flush()
843
-
844
-
845
773
  def store_mark_post_failed(batch, n, candidate_id=None, error=None):
846
774
  """A decided post that FAILED surfaces via notification/dashboard, not by
847
775
  re-presenting the card and not by endless resume retries."""
package/mcp/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m13v/s4l-mcp",
3
- "version": "1.7.2-rc.10",
3
+ "version": "1.7.2-rc.2",
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.2-rc.10",
3
+ "version": "1.7.2-rc.2",
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",
@@ -60,6 +60,7 @@ except Exception: # pragma: no cover - cosmetic only
60
60
 
61
61
  # script_tag -> queue type. ONLY pure text->JSON claude calls belong here.
62
62
  TAG_TO_TYPE = {
63
+ "run-twitter-cycle-queries": "twitter-query",
63
64
  "run-twitter-cycle-prep": "twitter-prep",
64
65
  "feedback-digest": "feedback-digest",
65
66
  # Topic-invention lane (queue-native since 2026-07-06; invent_topics.py
@@ -74,10 +75,11 @@ TAG_TO_TYPE = {
74
75
  }
75
76
 
76
77
  # queue type -> (activity state, label) the menu bar shows while the job is in
77
- # flight. Phase-2b prep is the reply drafting. Both the launchd provider (which
78
- # blocks for minutes) and the scheduled-task worker (which does the LLM turn)
79
- # narrate from this one map.
78
+ # flight. Phase-1 queries drive the X search ("finding threads"); Phase-2b prep is
79
+ # the reply drafting. Both the launchd provider (which blocks for minutes) and the
80
+ # scheduled-task worker (which does the LLM turn) narrate from this one map.
80
81
  TYPE_TO_ACTIVITY = {
82
+ "twitter-query": ("scanning", "search"),
81
83
  "twitter-prep": ("drafting", "draft"),
82
84
  "feedback-digest": ("learning", "feedback"),
83
85
  "invent-topic": ("learning", "new topic"),
@@ -129,31 +129,16 @@ STATS_KEYS = (
129
129
  )
130
130
 
131
131
 
132
- def _sync_with_backend(cands: list) -> tuple[int, int]:
133
- """One bulk /api/v1/twitter-candidates lookup for every still-open candidate
134
- (not posted, not terminal), used for two things:
135
-
136
- - stamp the discovery-time `stats` sidecar the card renders (candidates
137
- that already have one are left alone), same as the old
138
- _enrich_with_stats this replaces.
139
- - notice when the backend has ALREADY retired a candidate this plan
140
- still thinks is 'pending' (most commonly the Phase 0 freshness gate
141
- flipping status='expired' after FRESHNESS_HOURS see
142
- skill/run-twitter-cycle.sh) and mark it terminal here too, same as a
143
- human "discard all pending" would. Without this, a card can sit in
144
- the review queue as an approvable draft long after the backend has
145
- moved on; approving it later silently no-ops (post_drafts returns
146
- posted:0, no browser ever launches, no post-*.log — see the
147
- 2026-07-09 "approved 3 cards, nothing posted" investigation).
148
-
149
- Runs on EVERY merge (every cycle), not just once per candidate, so status
150
- drift after the initial stamp is still caught while a card is still
151
- pending. Best-effort: any API failure leaves every candidate untouched
152
- (fail open, same as before). Returns (stamped_count, pruned_count)."""
153
- pending = [c for c in cands if not c.get("posted") and not c.get("terminal") and _thread_url(c)]
154
- if not pending:
155
- return 0, 0
156
- urls = sorted({_thread_url(c) for c in pending})[:500]
132
+ def _enrich_with_stats(cands: list) -> int:
133
+ """Stamp a `stats` sidecar onto plan candidates that lack one, from the
134
+ twitter_candidates rows the discovery pipeline already wrote. ONE listing
135
+ call (/api/v1/twitter-candidates?tweet_urls=...) covers the whole queue.
136
+ Best-effort: any failure (offline box, missing identity, API error) leaves
137
+ candidates unstamped and NEVER blocks card delivery. Returns count stamped."""
138
+ want = [c for c in cands if not c.get("stats") and not c.get("posted") and _thread_url(c)]
139
+ if not want:
140
+ return 0
141
+ urls = sorted({_thread_url(c) for c in want})[:500]
157
142
  try:
158
143
  sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
159
144
  from http_api import api_get
@@ -164,24 +149,17 @@ def _sync_with_backend(cands: list) -> tuple[int, int]:
164
149
  )
165
150
  rows = (resp.get("data") or {}).get("candidates") or []
166
151
  except BaseException as e: # http_api raises SystemExit on terminal failure
167
- print(f"[merge_review_queue] backend sync skipped: {e}", file=sys.stderr)
168
- return 0, 0
152
+ print(f"[merge_review_queue] stats enrichment skipped: {e}", file=sys.stderr)
153
+ return 0
169
154
  by_url = {str(r.get("tweet_url")): r for r in rows if r.get("tweet_url")}
170
155
  stamped = 0
171
- pruned = 0
172
- for c in pending:
156
+ for c in want:
173
157
  row = by_url.get(_thread_url(c))
174
158
  if not row:
175
159
  continue
176
- if not c.get("stats"):
177
- c["stats"] = {k: row.get(k) for k in STATS_KEYS}
178
- stamped += 1
179
- status = row.get("status")
180
- if status and status != "pending":
181
- c["terminal"] = True
182
- c["discard_reason"] = f"backend_status_{status}"
183
- pruned += 1
184
- return stamped, pruned
160
+ c["stats"] = {k: row.get(k) for k in STATS_KEYS}
161
+ stamped += 1
162
+ return stamped
185
163
 
186
164
 
187
165
  def main() -> int:
@@ -277,15 +255,9 @@ def main() -> int:
277
255
  merged.append(c)
278
256
  added += 1
279
257
 
280
- stamped, pruned = _sync_with_backend(merged)
258
+ stamped = _enrich_with_stats(merged)
281
259
  if stamped:
282
260
  print(f"[merge_review_queue] stamped stats on {stamped} candidate(s)", file=sys.stderr)
283
- if pruned:
284
- print(
285
- f"[merge_review_queue] pruned {pruned} candidate(s) already retired by the "
286
- "backend (expired/etc.) before they were reviewed",
287
- file=sys.stderr,
288
- )
289
261
 
290
262
  plan_obj = {"candidates": merged}
291
263
  if plan_created_at:
@@ -302,17 +274,15 @@ def main() -> int:
302
274
  _atomic_write(dst, plan_obj)
303
275
  ensure_store_symlink()
304
276
 
305
- # Refresh the review-request marker the menu bar polls (count = pending,
306
- # not posted, not terminal -- a just-pruned expired card must not still
307
- # inflate the badge).
308
- pending_count = len([c for c in merged if not c.get("posted") and not c.get("terminal")])
277
+ # Refresh the review-request marker the menu bar polls (count = pending, not posted).
278
+ pending = len([c for c in merged if not c.get("posted")])
309
279
  project = ns.project or batch.get("project") or (new_cands[0].get("matched_project") if new_cands else None)
310
280
  _atomic_write(
311
281
  review_request_path(),
312
282
  {
313
283
  "batch_id": REVIEW_QUEUE_ID,
314
284
  "project": project,
315
- "count": pending_count,
285
+ "count": pending,
316
286
  "plan_path": dst,
317
287
  "created_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
318
288
  },
@@ -336,7 +306,7 @@ def main() -> int:
336
306
 
337
307
  print(
338
308
  f"[merge_review_queue] merged {added} new draft(s) into {REVIEW_QUEUE_ID} "
339
- f"({pending_count} pending total) from {os.path.basename(src)}",
309
+ f"({pending} pending total) from {os.path.basename(src)}",
340
310
  file=sys.stderr,
341
311
  )
342
312
  # Clean up the consumed batch plan so /tmp doesn't fill with orphans.
@@ -18,14 +18,10 @@ Safe by construction:
18
18
  - Best-effort: any single failure is logged and skipped; never raises.
19
19
 
20
20
  Degradation vs a normal cycle: salvaged candidates skip the cycle's post-provider
21
- top-N selection (so MORE cards, which is fine), lack the tail-link / experiments
22
- arm stamp that run-twitter-cycle.sh's plan writer adds after the provider returns,
23
- and (two-draft schema only) lack assigned_style/assigned_mode, which live in the
24
- cycle's shell variables, not the model output. The reply text itself IS complete
25
- end-to-end (_mirror_two_draft_fields backfills reply_text/drafts from
26
- draft_a_text/draft_b_text for the post-2026-07-07/08 two-draft schema, since the
27
- model output alone has no reply_text field to check for completeness). A salvaged
28
- card is strictly better than a lost draft.
21
+ top-N selection (so MORE cards, which is fine) and lack the tail-link / experiments
22
+ arm stamp that run-twitter-cycle.sh's plan writer adds after the provider returns.
23
+ The reply text itself is complete. A salvaged card is strictly better than a lost
24
+ draft.
29
25
 
30
26
  Usage:
31
27
  python3 scripts/salvage_orphaned_prep_results.py # automated (safe age gate)
@@ -63,73 +59,15 @@ except Exception: # standalone fallbacks
63
59
  pass
64
60
 
65
61
 
66
- def _mirror_two_draft_fields(candidates):
67
- """Backfill reply_text/drafts for two-draft-schema candidates (2026-07-07/08
68
- redesign) that reach salvage. The normal cycle path (run-twitter-cycle.sh)
69
- mirrors draft_a_text onto reply_text/engagement_style/drafts right after the
70
- model returns, but salvage bypasses that shell-side step entirely, so an
71
- orphaned post-redesign result reached review cards with NEITHER field set.
72
- The menubar card (s4l_card.py) reads d.get("drafts") first, then falls back
73
- to d.get("reply_text") or "", so those cards rendered the thread with a
74
- completely empty editable reply box despite draft_a_text/draft_b_text
75
- holding real, already-drafted content (root-caused 2026-07-09 via
76
- candidate 374925 and 4 siblings, all missing 'experiments' too, confirming
77
- they came through this salvage path rather than a normal cycle write).
78
-
79
- assigned_style/assigned_mode are deliberately left OUT (not set to None,
80
- just absent): the picker's per-cycle style assignment lives only in
81
- run-twitter-cycle.sh's shell variables, not in the model's JSON output, so
82
- it can't be recovered here. twitter_post_plan.py already has a documented
83
- fallback for that ("assigned_mode key absent" -> use the plan-level
84
- assignment, itself None for a salvaged plan), so leaving the keys out is
85
- the safe, already-supported degradation, same class as the existing
86
- no-experiments-stamp degradation.
87
- """
88
- for c in candidates:
89
- if not isinstance(c, dict) or "draft_a_text" not in c or "reply_text" in c:
90
- continue
91
- c["reply_text"] = c.get("draft_a_text") or ""
92
- c["engagement_style"] = c.get("draft_a_style") or ""
93
- c["new_style"] = c.get("draft_a_new_style")
94
- if c.get("draft_a_text_en"):
95
- c["reply_text_en"] = c["draft_a_text_en"]
96
- draft_b_text = c.get("draft_b_text")
97
- if not c.get("is_reused_draft") and draft_b_text:
98
- c["drafts"] = [
99
- {
100
- "variant": "a", "text": c.get("draft_a_text") or "",
101
- "style": c.get("draft_a_style") or "",
102
- "text_en": c.get("draft_a_text_en"),
103
- },
104
- {
105
- "variant": "b", "text": draft_b_text,
106
- "style": c.get("draft_b_style") or "",
107
- "text_en": c.get("draft_b_text_en"),
108
- },
109
- ]
110
-
111
-
112
62
  def _is_prep_result(obj):
113
- """True iff obj looks like a twitter-prep result (drafted candidates).
114
-
115
- "reply_text" was the single-draft field before the 2026-07-07/08 two-draft
116
- redesign (draft_a_text/draft_b_text per candidate, no single recommended
117
- reply). Checking only "reply_text" made every post-redesign orphaned
118
- result silently misclassified as non-prep and marked .skipped instead of
119
- recovered — the exact "worker drafted but no card" bug this script exists
120
- to prevent. Accept either field so both old and current schema results
121
- are recognized.
122
- """
63
+ """True iff obj looks like a twitter-prep result (candidates with reply_text)."""
123
64
  if not isinstance(obj, dict):
124
65
  return False
125
66
  cands = obj.get("candidates")
126
67
  if not isinstance(cands, list) or not cands:
127
68
  return False
128
69
  c0 = cands[0]
129
- if not isinstance(c0, dict):
130
- return False
131
- has_text = "reply_text" in c0 or "draft_a_text" in c0
132
- return has_text and ("candidate_url" in c0 or "candidate_id" in c0)
70
+ return isinstance(c0, dict) and "reply_text" in c0 and ("candidate_url" in c0 or "candidate_id" in c0)
133
71
 
134
72
 
135
73
  def main():
@@ -192,7 +130,6 @@ def main():
192
130
  pass
193
131
  continue
194
132
 
195
- _mirror_two_draft_fields(obj["candidates"])
196
133
  n = len(obj["candidates"])
197
134
  age_min = (now - st.st_mtime) / 60.0
198
135
  _plog(f"[salvage] ORPHAN prep result job {job_id}: producer never consumed it "
@@ -734,12 +734,14 @@ ENGAGED_COUNT=$(echo "$ENGAGED_TWEET_IDS" | python3 -c 'import json,sys; print(l
734
734
  log "Recently-engaged tweet IDs loaded: $ENGAGED_COUNT (last 48h; scanner will skip them)"
735
735
 
736
736
 
737
- # --- Phase 1: deterministic query bank, scrapes tweets ----------------------
738
- # No Claude call (removed 2026-07-08; see the query-bank comment below). The
739
- # Python pipeline runs each banked query via headless Chrome and writes the
740
- # tweets directly to SCAN_TWEETS_FILE for the shell.
741
-
742
- log "Acquiring twitter-browser lock for Phase 1 scan..."
737
+ # --- Phase 1: Claude drafts queries, scrapes tweets -------------------------
738
+ # JSON schema forces structured output. Eliminates the prose-drift failure mode
739
+ # Lean Phase 1 schema (2026-05-28): the scan session no longer scrapes,
740
+ # it only drafts queries. The Python pipeline runs each query via headless
741
+ # Chrome and writes the tweets directly to SCAN_TWEETS_FILE for the shell.
742
+ SCAN_SCHEMA_LEAN='{"type":"object","properties":{"queries":{"type":"array","items":{"type":"object","properties":{"project":{"type":"string"},"query":{"type":"string"},"search_topic":{"type":"string"}},"required":["project","query","search_topic"]}}},"required":["queries"]}'
743
+
744
+ log "Acquiring twitter-browser lock for Phase 1 Claude scan..."
743
745
  acquire_lock "twitter-browser" 3600 2>>"$LOG_FILE"
744
746
  log "twitter-browser lock held (pid=$$) Phase 1"
745
747
  # Drop stale Chrome singleton symlinks before launch. Background ungraceful-
@@ -909,15 +911,16 @@ log " Pre-flight access OK: $(printf '%s' "$_ACCESS_OUT" | tr '\n' ' ' | tr -s
909
911
  # cap is hit before target, proceed with whatever we have (even 1 candidate
910
912
  # is better than 0). When BATCH_COUNT is still 0 after the loop, the
911
913
  # post-loop empty_batch branch fires.
912
- # Phase 1 is the deterministic qualified-query bank (no Claude): the bank
913
- # replays every historically qualified query for the picked project in a
914
+ # DEFAULT Phase 1 is the deterministic qualified-query bank (no Claude): the
915
+ # bank replays every historically qualified query for the picked project in a
914
916
  # single pass, so there is nothing to "retry-draft" and one attempt is enough.
915
- # (The old LLM-draft path used a 5-attempt retry loop, because LLM-drafted
916
- # queries frequently returned empty and needed re-drafting; removed 2026-07-08
917
- # unused since the bank became the default 2026-05-28, and the queue-routed
918
- # claude call it made held the twitter-browser lock for no reason, the same
919
- # class of bug fixed in Phase 2b-prep the same day.)
920
- MAX_SCAN_ATTEMPTS=1
917
+ # The legacy LLM-draft path (TWITTER_PHASE1_LLM_DRAFT=1) keeps the 5-attempt
918
+ # retry loop, because LLM queries frequently return empty and need re-drafting.
919
+ if [ "${TWITTER_PHASE1_LLM_DRAFT:-0}" = "1" ]; then
920
+ MAX_SCAN_ATTEMPTS=5
921
+ else
922
+ MAX_SCAN_ATTEMPTS=1
923
+ fi
921
924
  RETRY_TARGET=5
922
925
  SCAN_ATTEMPT=0
923
926
  BATCH_COUNT=0
@@ -1070,8 +1073,110 @@ export SCAN_TWEETS_FILE
1070
1073
  # Output downstream is identical: $RAW_FILE + $QUERIES_FILE feed the scorer
1071
1074
  # and twitter_search_attempts logger the same way as before.
1072
1075
  #
1073
- # === DETERMINISTIC QUALIFIED-QUERY-BANK PATH (default since 2026-05-28;
1074
- # only path since 2026-07-08 removal of the LLM query-draft alternative) =====
1076
+ if [ "${TWITTER_PHASE1_LLM_DRAFT:-0}" = "1" ]; then
1077
+ # === LLM QUERY-DRAFT PATH (legacy, behind TWITTER_PHASE1_LLM_DRAFT=1) ========
1078
+ log "Lean Phase 1: drafting queries (no browser tools)..."
1079
+
1080
+ QUERIES_OUTPUT=$("$REPO_DIR/scripts/run_claude.sh" "run-twitter-cycle-queries" --strict-mcp-config --mcp-config "$TW_MCP_CONFIG" -p --output-format json --json-schema "$SCAN_SCHEMA_LEAN" "${TW_ENGINE_PREFIX}You are a Twitter query drafter. Your ONLY job is to draft fresh X advanced-search queries that surface tweets relevant to our projects. You do NOT post, you do NOT call any tools, you do NOT scrape. A separate Python pipeline runs your queries over the same CDP-driven Chrome and applies a strict freshness gate; you only return the query strings.
1081
+
1082
+ ## Step 1: Draft one search query per project
1083
+
1084
+ You have $(echo "$PROJECTS_JSON" | python3 -c 'import json,sys; print(len(json.load(sys.stdin)))') projects. Draft exactly ONE Twitter search query for each, tailored to that project's ASSIGNED search_topic.
1085
+
1086
+ Each project entry carries TWO fields that drive your behavior: \`topic_picked_mode\` (either \`use\` or \`explore_invent\`) and \`search_topic\` (a string in \`use\` mode, NULL in \`explore_invent\` mode).
1087
+
1088
+ USE mode (~90% of cycles, indicated by \`topic_picked_mode: \"use\"\` and a non-null \`search_topic\`):
1089
+ The Python picker has already chosen this project's search_topic by weighted-random sampling over the FULL universe in config.json. Your job is to translate that ASSIGNED topic into the best Twitter advanced-search query that will surface fresh, on-topic tweets. Do NOT substitute a different topic; do NOT paraphrase the topic. End-to-end attribution joins on the exact string.
1090
+
1091
+ EXPLORE_INVENT mode (~10% of cycles, indicated by \`topic_picked_mode: \"explore_invent\"\` and \`search_topic: null\`):
1092
+ The picker is asking you to INVENT a brand-new search_topic. Look at the project's own \`reference_topics\` array and propose ONE new topic concept that does NOT appear there and is NOT a paraphrase of anything in it. Use your invented topic as the query's \`search_topic\` AND drive the keyword phrasing from it (one consistent string per project).
1093
+
1094
+ Projects:
1095
+ $PROJECTS_JSON
1096
+
1097
+ Top past queries FOR THE PROJECT YOU'RE DRAFTING FOR (7-day window, per-project, sorted by clicks DESC first, then composite-scored: clicks×100 + likes + views×0.001). CLICKS ARE THE PRIORITY SIGNAL. Each row carries THREE labels that tell you what to do with it as a reference:
1098
+
1099
+ - \`supply_bucket\`: low (<1 tweet/attempt), medium (1-5), high (>5). Raw supply X returned for this phrasing.
1100
+ - \`conversion_bucket\`: low (<0.2 post_rate), medium (0.2-0.6), high (>=0.6). How often a found tweet survived the draft gate.
1101
+ - \`guidance\`: one of MIMIC, KEEP_STYLE, NARROW, BROADEN — the action to take when drawing from this query.
1102
+ - \`posts_per_attempt\`: posts produced per Phase 1 search invocation; <0.1 means most attempts produce zero survivors.
1103
+
1104
+ How to act on \`guidance\`:
1105
+ - MIMIC — gold tier. Reuse the operator skeleton verbatim, swap only the topic keyword for the picker-assigned topic.
1106
+ - KEEP_STYLE — solid. Use the operator pattern as inspiration; small phrasing tweaks OK.
1107
+ - NARROW — high supply, low conversion (noisy pond). If you draw from it, ADD specificity: more OR alternates, stricter min_faves, extra -term excludes.
1108
+ - BROADEN — low supply (query dying or topic running dry). The OPERATORS are dead weight. Shorten to 1-2 keywords, drop OR groups, step min_faves down a tier. Do NOT inherit operators from a BROADEN-tagged row.
1109
+
1110
+ The canonical source for \`min_faves:N\` selection is the PER-PROJECT SUPPLY SIGNAL block below.
1111
+ $TOP_QUERIES_PER_PROJECT_JSON
1112
+
1113
+ TOP-PERFORMING SEARCH TOPICS (conceptual seeds, 14d window) — context for query phrasing only; you draft a query for the picker-assigned topic, you do NOT swap topics here:
1114
+ $TOP_TOPICS_JSON
1115
+
1116
+ DUD QUERIES — DO NOT REUSE these phrasings or close variants. They returned ZERO tweets in the last 48h:
1117
+ $DUD_QUERIES_JSON
1118
+
1119
+ DUD CONCEPT SEEDS — these search_topic seeds pulled in tweets that Phase 2b's draft gate kept skipping over the last 7d. Per entry: \`omit_rate\` = skipped_n / (posted_n + skipped_n), \`sample_skip_reasons\` are the top reject reasons. If \`omit_rate >= 0.6\` AND \`skipped_n >= 5\`, REWORD the query narrower or drop the seed and pick a different config.json seed for that project:
1120
+ $DUD_TOPICS_JSON
1121
+
1122
+ PER-PROJECT SUPPLY SIGNAL — for each project, the historical median tweets_found at each \`min_faves:N\` tier you've drafted in the last 14d. Pick the LOWEST tier where \`median_tweets_found >= 3\`; if every tier is below 3, drop one tier lower than the lowest you've tried. Trust this table over priors:
1123
+ $SUPPLY_SIGNAL_JSON
1124
+
1125
+ ALREADY-ENGAGED TWEET IDS (last 48h) — the Python scraper skips these regardless, but knowing them helps you avoid drafting a query that would predominantly surface dead candidates:
1126
+ $ENGAGED_TWEET_IDS
1127
+
1128
+ THIS-CYCLE QUERIES ALREADY TRIED with per-query outcomes (attempt $SCAN_ATTEMPT/$MAX_SCAN_ATTEMPTS, target=$RETRY_TARGET candidates after filters). Do NOT repeat any of these phrasings or close variants. Read each entry's \`verdict\` field and respond directionally (do NOT default to generic "broaden"):
1129
+ - \`dead_supply\` (raw_tweets=0): the phrasing returned ZERO tweets from X. The query was too narrow for X's index. HARD RULE: attempt N+1 MUST execute at least ONE of these THREE concrete broadening moves, NOT a topic rotation. Pick exactly one and apply it visibly: (a) lower \`min_faves\` by ONE FULL TIER (e.g. 20→5, 5→1, 1→0); (b) reduce the OR alternates inside any parenthesized group to AT MOST 2 terms (e.g. \`(A OR B OR C OR D)\` → \`(A OR B)\`); (c) drop ALL \`-term\` excludes EXCEPT those listed in this project's \`excludes_for_search\` (which remain mandatory). The PER-PROJECT SUPPLY SIGNAL block is OVERRIDDEN by \`dead_supply\` THIS CYCLE — do not appeal to historical min_faves when the current attempt returned 0. Swapping the topic noun while keeping the same operator skeleton is NOT broadening and is FORBIDDEN as a response to \`dead_supply\`.
1130
+ - \`all_aged_out\` (raw>0, kept_after_age=0): topic is supply-limited at the current freshness window; every tweet was older than the cap. Pick a structurally adjacent topic; do NOT rephrase the same one (it will just hit the cap again).
1131
+ - \`all_engaged_or_skipped\` (kept_after_age>0, kept_after_skip=0): query phrasing is fine, but the surviving tweets were already engaged on prior cycles. Pick a DIFFERENT topic, not a rephrase.
1132
+ - \`found_some\` (kept_after_skip>0 but below target): query is on-target. Raise min_faves one tier OR add a semantic constraint to lift quality. Do NOT broaden.
1133
+ $TRIED_QUERIES_JSON
1134
+
1135
+ Query guidelines:
1136
+ - MANDATORY: do NOT add any date or time-window operator to your query (no \`since:\`, \`until:\`, \`since_time:\`, \`until_time:\`). The Python scraper enforces the freshness window at the URL level after you return; any time operator you include is stripped and overwritten. Including raw bash arithmetic, format strings, or placeholder text in place of a real epoch will be sent to X as a literal keyword and produce zero results.
1137
+ - MANDATORY EVEN IF YOUR QUERY KEYWORDS DO NOT NAME THE EXCLUDED TOPIC: if a project's \`excludes_for_search\` array is non-empty, append \`-term\` for EVERY listed term to that project's query, verbatim, no exceptions.
1138
+ - MANDATORY: pick \`min_faves:N\` per the PER-PROJECT SUPPLY SIGNAL above. If a project has no entry there (new / first cycle), start at min_faves:20.
1139
+ - Favor discussions/opinions (people sharing experience, asking questions), not news/promos/giveaways.
1140
+ - Pick a query likely to surface tweets RELEVANT to that project's actual domain.
1141
+ - Mix it up each run; don't always use the same query for the same project.
1142
+ - Use the project's ASSIGNED \`search_topic\` plus its \`description\` as grounding for query phrasing.
1143
+ - The \`search_topic\` you emit in the output JSON MUST be the project's assigned \`search_topic\` field pasted VERBATIM (NOT the query string, NOT a paraphrase). The scoring pipeline stamps \`twitter_candidates.search_topic\` from this for end-to-end attribution.
1144
+
1145
+ ## Output
1146
+
1147
+ Return ONLY the structured_output JSON with this shape:
1148
+ {\"queries\": [{\"project\": \"PROJECT_NAME\", \"query\": \"X advanced search string with operators\", \"search_topic\": \"assigned or invented topic, verbatim\"}, ...]}
1149
+
1150
+ One entry per project. Do NOT include tweets, do NOT include tweets_found, do NOT call any tool, do NOT scrape. The shell pipeline runs each query via headless Chrome with a strict freshness gate after you return." 2>&1)
1151
+
1152
+
1153
+ # Dump the captured envelope to the cycle log for offline inspection.
1154
+ echo "$QUERIES_OUTPUT" >> "$LOG_FILE"
1155
+
1156
+ # Extract the drafted queries to a temp file.
1157
+ QUERIES_TMP="/tmp/twcycle-${BATCH_ID}-attempt-${SCAN_ATTEMPT}-queries.json"
1158
+ python3 -c "
1159
+ import json, sys
1160
+ text = sys.stdin.read().strip()
1161
+ try:
1162
+ env, _ = json.JSONDecoder().raw_decode(text)
1163
+ except Exception as e:
1164
+ print(f'lean phase 1: envelope parse error: {e}', file=sys.stderr)
1165
+ json.dump([], open('$QUERIES_TMP', 'w'))
1166
+ sys.exit(0)
1167
+ so = env.get('structured_output')
1168
+ if so is None:
1169
+ so = env.get('result')
1170
+ if isinstance(so, str):
1171
+ try: so = json.loads(so)
1172
+ except Exception: pass
1173
+ qs = so.get('queries', []) if isinstance(so, dict) else []
1174
+ json.dump(qs, open('$QUERIES_TMP', 'w'))
1175
+ print(f'lean phase 1: drafted {len(qs)} queries', flush=True)
1176
+ " <<< "$QUERIES_OUTPUT" 2>&1 | tee -a "$LOG_FILE"
1177
+
1178
+ else
1179
+ # === DETERMINISTIC QUALIFIED-QUERY-BANK PATH (default, 2026-05-28) ==========
1075
1180
  # No Claude call. Replay every historically qualified query for the picked
1076
1181
  # project(s): every distinct query that ever produced a posted reply with
1077
1182
  # >=1 like OR >=1 non-bot link click, regardless of the per-cycle
@@ -1087,6 +1192,7 @@ else
1087
1192
  log "Phase 1 (bank): building qualified query bank from PROJECTS_JSON (deterministic, no Claude)..."
1088
1193
  echo "$PROJECTS_JSON" | python3 "$REPO_DIR/scripts/qualified_query_bank.py" --from-projects-json > "$QUERIES_TMP" 2>>"$LOG_FILE"
1089
1194
  fi
1195
+ fi
1090
1196
 
1091
1197
  QUERIES_COUNT=$(python3 -c "
1092
1198
  import json