@m13v/s4l 1.7.2-rc.3 → 1.7.2-rc.5

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.2-rc.3",
3
- "installedAt": "2026-07-09T01:15:01.267Z"
2
+ "version": "1.7.2-rc.5",
3
+ "installedAt": "2026-07-09T01:43:14.354Z"
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.3",
5
+ "version": "1.7.2-rc.5",
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": {
@@ -1140,7 +1140,7 @@ class S4LMenuBar(rumps.App):
1140
1140
  "Uninstall: keep your X login + browser layer (quick uninstall).\n"
1141
1141
  "Deep wipe: also remove the shared browser profiles + toolchain."
1142
1142
  ),
1143
- ok="Uninstall & Restart Claude", cancel="Cancel", other="Deep wipe",
1143
+ ok="Uninstall & Restart Claude", cancel="Cancel", other="Deep wipe & Restart Claude",
1144
1144
  )
1145
1145
  if choice == 0: # cancel
1146
1146
  return
@@ -3001,7 +3001,6 @@ class S4LMenuBar(rumps.App):
3001
3001
  items.append(rumps.MenuItem("Resume S4L", callback=self._pause_toggle))
3002
3002
  else:
3003
3003
  items.append(rumps.MenuItem("Pause S4L", callback=self._pause_toggle))
3004
- items.append(self._label(" stop drafting/posting, keep Claude + tray running"))
3005
3004
  items.append(rumps.separator)
3006
3005
 
3007
3006
  # Attention = the draft schedule isn't running for THIS account (missing or
@@ -3105,19 +3104,15 @@ class S4LMenuBar(rumps.App):
3105
3104
  personal_on = bool(flags.get("personal_brand"))
3106
3105
  promo_on = bool(flags.get("promotion"))
3107
3106
  items.append(rumps.separator)
3108
- items.append(self._label("Engagement lanes"))
3109
3107
  pb_item = rumps.MenuItem("Personal brand", callback=self._toggle_personal)
3110
3108
  pb_item.state = 1 if personal_on else 0
3111
3109
  items.append(pb_item)
3112
- items.append(self._label(" organic, link-free engagement"))
3113
3110
  pr_item = rumps.MenuItem("Product promotion", callback=self._toggle_promotion)
3114
3111
  pr_item.state = 1 if promo_on else 0
3115
3112
  items.append(pr_item)
3116
- items.append(self._label(" promoting your products (link replies)"))
3117
3113
  if personal_on and promo_on:
3118
3114
  # Both lanes on: the split becomes meaningful, so offer the presets.
3119
3115
  share = st.read_split()
3120
- items.append(self._label(f" both on · cycles split {self._split_pct(share)}"))
3121
3116
  split_menu = rumps.MenuItem(f"Lane split: {self._split_pct(share)}")
3122
3117
  for preset in self.SPLIT_PRESETS:
3123
3118
  it = rumps.MenuItem(
@@ -3134,7 +3129,6 @@ class S4LMenuBar(rumps.App):
3134
3129
  # carries a Feedback button); named for what it does to the pipeline,
3135
3130
  # not the mechanism.
3136
3131
  items.append(rumps.MenuItem("Give overall feedback to AI…", callback=self._menu_feedback))
3137
- items.append(self._label(" overall guidance, steers future drafts"))
3138
3132
  # While the update-verify marker is pending, the pipeline copy still
3139
3133
  # resolves the OLD version (it only advances once the restarted server
3140
3134
  # re-provisions repo/package, ~2 min), so the snapshot honestly reports
@@ -3161,8 +3155,10 @@ class S4LMenuBar(rumps.App):
3161
3155
  items.append(rumps.separator)
3162
3156
  items.append(rumps.MenuItem("Tidy autopilot history…", callback=self._prompt_relocate_tasks))
3163
3157
  items.append(rumps.separator)
3164
- items.append(rumps.MenuItem("Uninstall S4L…", callback=self._reset_machine))
3165
- items.append(rumps.MenuItem("Quit", callback=self._quit_app))
3158
+ quit_menu = rumps.MenuItem("Quit or Uninstall")
3159
+ quit_menu.add(rumps.MenuItem("Uninstall S4L…", callback=self._reset_machine))
3160
+ quit_menu.add(rumps.MenuItem("Quit", callback=self._quit_app))
3161
+ items.append(quit_menu)
3166
3162
 
3167
3163
  # Collapse consecutive/edge separators so an empty section (e.g. State C
3168
3164
  # now renders no status labels) can't leave a doubled or dangling divider.
package/mcp/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m13v/s4l-mcp",
3
- "version": "1.7.2-rc.3",
3
+ "version": "1.7.2-rc.5",
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.3",
3
+ "version": "1.7.2-rc.5",
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,7 +60,6 @@ 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",
64
63
  "run-twitter-cycle-prep": "twitter-prep",
65
64
  "feedback-digest": "feedback-digest",
66
65
  # Topic-invention lane (queue-native since 2026-07-06; invent_topics.py
@@ -75,11 +74,10 @@ TAG_TO_TYPE = {
75
74
  }
76
75
 
77
76
  # queue type -> (activity state, label) the menu bar shows while the job is in
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.
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.
81
80
  TYPE_TO_ACTIVITY = {
82
- "twitter-query": ("scanning", "search"),
83
81
  "twitter-prep": ("drafting", "draft"),
84
82
  "feedback-digest": ("learning", "feedback"),
85
83
  "invent-topic": ("learning", "new topic"),
@@ -60,14 +60,26 @@ except Exception: # standalone fallbacks
60
60
 
61
61
 
62
62
  def _is_prep_result(obj):
63
- """True iff obj looks like a twitter-prep result (candidates with reply_text)."""
63
+ """True iff obj looks like a twitter-prep result (drafted candidates).
64
+
65
+ "reply_text" was the single-draft field before the 2026-07-07/08 two-draft
66
+ redesign (draft_a_text/draft_b_text per candidate, no single recommended
67
+ reply). Checking only "reply_text" made every post-redesign orphaned
68
+ result silently misclassified as non-prep and marked .skipped instead of
69
+ recovered — the exact "worker drafted but no card" bug this script exists
70
+ to prevent. Accept either field so both old and current schema results
71
+ are recognized.
72
+ """
64
73
  if not isinstance(obj, dict):
65
74
  return False
66
75
  cands = obj.get("candidates")
67
76
  if not isinstance(cands, list) or not cands:
68
77
  return False
69
78
  c0 = cands[0]
70
- return isinstance(c0, dict) and "reply_text" in c0 and ("candidate_url" in c0 or "candidate_id" in c0)
79
+ if not isinstance(c0, dict):
80
+ return False
81
+ has_text = "reply_text" in c0 or "draft_a_text" in c0
82
+ return has_text and ("candidate_url" in c0 or "candidate_id" in c0)
71
83
 
72
84
 
73
85
  def main():
@@ -734,14 +734,12 @@ 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: 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..."
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..."
745
743
  acquire_lock "twitter-browser" 3600 2>>"$LOG_FILE"
746
744
  log "twitter-browser lock held (pid=$$) Phase 1"
747
745
  # Drop stale Chrome singleton symlinks before launch. Background ungraceful-
@@ -911,16 +909,15 @@ log " Pre-flight access OK: $(printf '%s' "$_ACCESS_OUT" | tr '\n' ' ' | tr -s
911
909
  # cap is hit before target, proceed with whatever we have (even 1 candidate
912
910
  # is better than 0). When BATCH_COUNT is still 0 after the loop, the
913
911
  # post-loop empty_batch branch fires.
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
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
916
914
  # single pass, so there is nothing to "retry-draft" and one attempt is enough.
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
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
924
921
  RETRY_TARGET=5
925
922
  SCAN_ATTEMPT=0
926
923
  BATCH_COUNT=0
@@ -1073,110 +1070,8 @@ export SCAN_TWEETS_FILE
1073
1070
  # Output downstream is identical: $RAW_FILE + $QUERIES_FILE feed the scorer
1074
1071
  # and twitter_search_attempts logger the same way as before.
1075
1072
  #
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) ==========
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) =====
1180
1075
  # No Claude call. Replay every historically qualified query for the picked
1181
1076
  # project(s): every distinct query that ever produced a posted reply with
1182
1077
  # >=1 like OR >=1 non-bot link click, regardless of the per-cycle
@@ -1192,7 +1087,6 @@ else
1192
1087
  log "Phase 1 (bank): building qualified query bank from PROJECTS_JSON (deterministic, no Claude)..."
1193
1088
  echo "$PROJECTS_JSON" | python3 "$REPO_DIR/scripts/qualified_query_bank.py" --from-projects-json > "$QUERIES_TMP" 2>>"$LOG_FILE"
1194
1089
  fi
1195
- fi
1196
1090
 
1197
1091
  QUERIES_COUNT=$(python3 -c "
1198
1092
  import json