luckiest-co 1.0.47 → 1.0.49

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.
Files changed (274) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/package.json +1 -1
  4. package/skills/ab-testing/CHANGELOG.md +5 -0
  5. package/skills/ab-testing/SKILL.md +12 -9
  6. package/skills/ab-testing/evals/evals.json +18 -7
  7. package/skills/ab-testing/references/sample-size-guide.md +2 -2
  8. package/skills/ads/CHANGELOG.md +5 -0
  9. package/skills/ads/SKILL.md +4 -4
  10. package/skills/ai-seo/CHANGELOG.md +7 -0
  11. package/skills/ai-seo/SKILL.md +27 -20
  12. package/skills/analytics/CHANGELOG.md +6 -0
  13. package/skills/analytics/SKILL.md +4 -3
  14. package/skills/analytics/evals/evals.json +27 -0
  15. package/skills/analytics/references/ga4-implementation.md +7 -3
  16. package/skills/analytics/references/gtm-implementation.md +8 -2
  17. package/skills/aso/CHANGELOG.md +5 -0
  18. package/skills/aso/SKILL.md +4 -4
  19. package/skills/aso/evals/evals.json +15 -2
  20. package/skills/aso/references/benchmarks.md +4 -2
  21. package/skills/aso/references/google-play-specs.md +5 -1
  22. package/skills/brainstorm/SKILL.md +59 -16
  23. package/skills/brainstorm/builder-check-prompt.md +23 -0
  24. package/skills/churn-prevention/CHANGELOG.md +5 -0
  25. package/skills/churn-prevention/SKILL.md +3 -3
  26. package/skills/churn-prevention/evals/evals.json +14 -0
  27. package/skills/churn-prevention/references/dunning-playbook.md +6 -1
  28. package/skills/coder-brainstorming/CHANGELOG.md +10 -0
  29. package/skills/coder-brainstorming/SKILL.md +62 -20
  30. package/skills/coder-brainstorming/builder-check-prompt.md +23 -0
  31. package/skills/coder-brainstorming/scripts/server.cjs +1 -1
  32. package/skills/coder-debugging/CHANGELOG.md +4 -0
  33. package/skills/coder-debugging/SKILL.md +5 -5
  34. package/skills/coder-requesting-code-review/CHANGELOG.md +4 -0
  35. package/skills/coder-requesting-code-review/SKILL.md +4 -4
  36. package/skills/coder-subagent-driven-development/CHANGELOG.md +4 -0
  37. package/skills/coder-subagent-driven-development/SKILL.md +3 -3
  38. package/skills/coder-subagent-driven-development/scripts/sdd-workspace +3 -1
  39. package/skills/cold-email/CHANGELOG.md +7 -0
  40. package/skills/cold-email/SKILL.md +27 -7
  41. package/skills/cold-email/evals/evals.json +30 -0
  42. package/skills/cold-email/references/benchmarks.md +15 -11
  43. package/skills/cold-email/references/deliverability.md +110 -0
  44. package/skills/cold-email/references/linkedin-outreach.md +91 -0
  45. package/skills/cold-email/references/multichannel-cadence.md +79 -0
  46. package/skills/cold-email/references/outbound-plays.md +88 -0
  47. package/skills/cold-email/references/personalization.md +11 -0
  48. package/skills/cold-email/references/reply-handling.md +65 -0
  49. package/skills/competitor-profiling/CHANGELOG.md +5 -0
  50. package/skills/competitor-profiling/SKILL.md +4 -4
  51. package/skills/content-strategy/CHANGELOG.md +5 -0
  52. package/skills/content-strategy/SKILL.md +5 -3
  53. package/skills/copy-editing/CHANGELOG.md +5 -0
  54. package/skills/copy-editing/SKILL.md +5 -5
  55. package/skills/copy-editing/references/content-refresh.md +33 -0
  56. package/skills/cro/CHANGELOG.md +5 -0
  57. package/skills/cro/SKILL.md +14 -3
  58. package/skills/cro/references/form.md +1 -1
  59. package/skills/directory-submissions/CHANGELOG.md +5 -0
  60. package/skills/directory-submissions/SKILL.md +13 -4
  61. package/skills/directory-submissions/evals/evals.json +15 -1
  62. package/skills/directory-submissions/references/directory-list.md +2 -4
  63. package/skills/emails/CHANGELOG.md +5 -0
  64. package/skills/emails/SKILL.md +14 -4
  65. package/skills/launch/CHANGELOG.md +7 -0
  66. package/skills/launch/SKILL.md +8 -4
  67. package/skills/launch/evals/evals.json +29 -0
  68. package/skills/launch/references/shipped-changes.md +84 -0
  69. package/skills/launch/references/site-launch-qa.md +55 -0
  70. package/skills/lead-magnets/CHANGELOG.md +5 -0
  71. package/skills/lead-magnets/SKILL.md +6 -4
  72. package/skills/lead-magnets/evals/evals.json +18 -0
  73. package/skills/lead-magnets/references/content-to-skill-corpus-design.md +89 -0
  74. package/skills/lead-magnets/references/content-to-skill-release.md +71 -0
  75. package/skills/lead-magnets/references/content-to-skill.md +108 -0
  76. package/skills/marketing-plan/CHANGELOG.md +5 -0
  77. package/skills/marketing-plan/SKILL.md +3 -3
  78. package/skills/marketing-plan/evals/evals.json +13 -0
  79. package/skills/marketing-plan/references/budget-planning.md +26 -17
  80. package/skills/model-router/CHANGELOG.md +6 -0
  81. package/skills/model-router/SKILL.md +3 -3
  82. package/skills/model-router/scripts/route.py +1 -1
  83. package/skills/model-router/scripts/test_route.py +5 -0
  84. package/skills/pricing/CHANGELOG.md +5 -0
  85. package/skills/pricing/SKILL.md +4 -4
  86. package/skills/pricing/evals/evals.json +12 -0
  87. package/skills/pricing/references/research-methods.md +8 -4
  88. package/skills/programmatic-seo/CHANGELOG.md +5 -0
  89. package/skills/programmatic-seo/SKILL.md +8 -4
  90. package/skills/programmatic-seo/evals/evals.json +13 -0
  91. package/skills/programmatic-seo/references/implementation-platforms.md +37 -0
  92. package/skills/prospecting/CHANGELOG.md +7 -0
  93. package/skills/prospecting/SKILL.md +27 -13
  94. package/skills/prospecting/evals/evals.json +41 -0
  95. package/skills/prospecting/references/account-research.md +59 -0
  96. package/skills/prospecting/references/b2b-prospecting.md +1 -1
  97. package/skills/prospecting/references/compliance.md +44 -6
  98. package/skills/prospecting/references/data-sources.md +13 -15
  99. package/skills/prospecting/references/enrichment-playbook.md +61 -0
  100. package/skills/prospecting/references/saas-prospecting.md +1 -1
  101. package/skills/prospecting/references/signal-plays.md +99 -0
  102. package/skills/prospecting/references/sourcing-and-enrichment.md +89 -0
  103. package/skills/referrals/CHANGELOG.md +5 -0
  104. package/skills/referrals/SKILL.md +10 -4
  105. package/skills/referrals/evals/evals.json +99 -0
  106. package/skills/referrals/references/affiliate-operations.md +64 -0
  107. package/skills/referrals/references/affiliate-programs.md +2 -2
  108. package/skills/referrals/references/program-examples.md +14 -6
  109. package/skills/referrals/references/review-platform-rules.md +36 -0
  110. package/skills/referrals/references/review-program-operations.md +62 -0
  111. package/skills/referrals/references/review-programs.md +96 -0
  112. package/skills/revops/CHANGELOG.md +5 -0
  113. package/skills/revops/SKILL.md +10 -6
  114. package/skills/revops/evals/evals.json +13 -0
  115. package/skills/revops/references/automation-playbooks.md +1 -1
  116. package/skills/revops/references/inbound-routing.md +43 -0
  117. package/skills/revops/references/lifecycle-definitions.md +31 -0
  118. package/skills/sales-enablement/CHANGELOG.md +5 -0
  119. package/skills/sales-enablement/SKILL.md +6 -4
  120. package/skills/sales-enablement/evals/evals.json +14 -0
  121. package/skills/sales-enablement/references/cold-call-scripts.md +68 -0
  122. package/skills/schema/CHANGELOG.md +5 -0
  123. package/skills/schema/SKILL.md +20 -8
  124. package/skills/schema/evals/evals.json +40 -0
  125. package/skills/schema/references/schema-examples.md +9 -15
  126. package/skills/seo-audit/CHANGELOG.md +5 -0
  127. package/skills/seo-audit/SKILL.md +10 -16
  128. package/skills/seo-audit/evals/evals.json +68 -0
  129. package/skills/seo-audit/references/international-seo.md +9 -11
  130. package/skills/seo-audit/references/local-seo-pages-and-measurement.md +39 -0
  131. package/skills/seo-audit/references/local-seo-profile-and-citations.md +41 -0
  132. package/skills/seo-audit/references/local-seo.md +86 -0
  133. package/skills/seo-audit/references/rankings-push.md +62 -0
  134. package/skills/seo-audit/references/title-tags.md +89 -0
  135. package/skills/signup/CHANGELOG.md +5 -0
  136. package/skills/signup/SKILL.md +7 -3
  137. package/skills/site-architecture/CHANGELOG.md +5 -0
  138. package/skills/site-architecture/SKILL.md +5 -4
  139. package/skills/site-architecture/evals/evals.json +25 -0
  140. package/skills/site-architecture/references/platforms-and-migration.md +84 -0
  141. package/skills/social/CHANGELOG.md +5 -0
  142. package/skills/social/SKILL.md +4 -4
  143. package/skills/trends/.skillignore +3 -0
  144. package/skills/trends/CHANGELOG.md +20 -0
  145. package/skills/trends/SKILL.md +12 -8
  146. package/skills/trends/references/agent-mode.md +36 -0
  147. package/skills/trends/references/category-peers.md +38 -0
  148. package/skills/trends/references/comparison.md +227 -0
  149. package/skills/trends/references/competitors.md +29 -0
  150. package/skills/trends/references/discovery.md +90 -0
  151. package/skills/trends/references/engine-contract.md +111 -1889
  152. package/skills/trends/references/followups.md +112 -0
  153. package/skills/trends/references/grok-bot-x.md +55 -0
  154. package/skills/trends/references/hiring-signals.md +19 -0
  155. package/skills/trends/references/library-queue.md +33 -0
  156. package/skills/trends/references/recommendations.md +86 -0
  157. package/skills/trends/references/research-runbook.md +781 -0
  158. package/skills/trends/references/runtime.md +165 -0
  159. package/skills/trends/references/save-html-brief.md +39 -20
  160. package/skills/trends/references/setup-wizard.md +253 -0
  161. package/skills/trends/references/synthesis.md +289 -0
  162. package/skills/trends/scripts/box_chrome_login.py +209 -0
  163. package/skills/trends/scripts/briefing.py +9 -17
  164. package/skills/trends/scripts/last30days.py +3247 -280
  165. package/skills/trends/scripts/lib/agentcookie.py +196 -0
  166. package/skills/trends/scripts/lib/amazon.py +877 -0
  167. package/skills/trends/scripts/lib/arxiv.py +42 -8
  168. package/skills/trends/scripts/lib/backends.py +769 -0
  169. package/skills/trends/scripts/lib/bird_x.py +340 -57
  170. package/skills/trends/scripts/lib/bluesky.py +84 -10
  171. package/skills/trends/scripts/lib/bounded_get.py +156 -0
  172. package/skills/trends/scripts/lib/brightdata.py +291 -0
  173. package/skills/trends/scripts/lib/categories.py +7 -1
  174. package/skills/trends/scripts/lib/chrome_cdp.py +455 -0
  175. package/skills/trends/scripts/lib/chrome_cookies.py +132 -39
  176. package/skills/trends/scripts/lib/cluster.py +31 -54
  177. package/skills/trends/scripts/lib/competitors.py +10 -1
  178. package/skills/trends/scripts/lib/cookie_extract.py +221 -44
  179. package/skills/trends/scripts/lib/cookie_paths.py +27 -0
  180. package/skills/trends/scripts/lib/corpus.py +439 -0
  181. package/skills/trends/scripts/lib/devto.py +313 -0
  182. package/skills/trends/scripts/lib/discovery_handoff.py +988 -0
  183. package/skills/trends/scripts/lib/doctor.py +2166 -0
  184. package/skills/trends/scripts/lib/dripstack.py +203 -0
  185. package/skills/trends/scripts/lib/entity_extract.py +37 -0
  186. package/skills/trends/scripts/lib/env.py +1092 -138
  187. package/skills/trends/scripts/lib/fanout.py +11 -1
  188. package/skills/trends/scripts/lib/feed.py +61 -0
  189. package/skills/trends/scripts/lib/fetch_memo.py +135 -0
  190. package/skills/trends/scripts/lib/freshness.py +566 -0
  191. package/skills/trends/scripts/lib/fusion.py +314 -28
  192. package/skills/trends/scripts/lib/github.py +438 -24
  193. package/skills/trends/scripts/lib/grok_x.py +1237 -0
  194. package/skills/trends/scripts/lib/grounding.py +129 -11
  195. package/skills/trends/scripts/lib/hackernews.py +61 -4
  196. package/skills/trends/scripts/lib/health.py +373 -0
  197. package/skills/trends/scripts/lib/hosted.py +333 -0
  198. package/skills/trends/scripts/lib/html_publish.py +34 -0
  199. package/skills/trends/scripts/lib/html_render.py +169 -3
  200. package/skills/trends/scripts/lib/http.py +1215 -70
  201. package/skills/trends/scripts/lib/instagram.py +132 -9
  202. package/skills/trends/scripts/lib/jobs.py +4 -2
  203. package/skills/trends/scripts/lib/library.py +308 -0
  204. package/skills/trends/scripts/lib/library_index.py +477 -0
  205. package/skills/trends/scripts/lib/linkedin.py +271 -24
  206. package/skills/trends/scripts/lib/log.py +5 -2
  207. package/skills/trends/scripts/lib/meta_ads.py +982 -0
  208. package/skills/trends/scripts/lib/normalize.py +382 -28
  209. package/skills/trends/scripts/lib/parallel_mcp.py +234 -0
  210. package/skills/trends/scripts/lib/permission_preflight.py +42 -3
  211. package/skills/trends/scripts/lib/perplexity.py +1080 -468
  212. package/skills/trends/scripts/lib/pipeline.py +5079 -211
  213. package/skills/trends/scripts/lib/planner.py +396 -53
  214. package/skills/trends/scripts/lib/polymarket.py +285 -23
  215. package/skills/trends/scripts/lib/prescriptions.py +432 -0
  216. package/skills/trends/scripts/lib/private_files.py +21 -0
  217. package/skills/trends/scripts/lib/providers.py +231 -10
  218. package/skills/trends/scripts/lib/quality_nudge.py +122 -37
  219. package/skills/trends/scripts/lib/reddit.py +177 -12
  220. package/skills/trends/scripts/lib/reddit_arctic.py +146 -5
  221. package/skills/trends/scripts/lib/reddit_keyless.py +121 -60
  222. package/skills/trends/scripts/lib/reddit_listing.py +215 -14
  223. package/skills/trends/scripts/lib/reddit_public.py +12 -13
  224. package/skills/trends/scripts/lib/reddit_search.py +304 -0
  225. package/skills/trends/scripts/lib/reddit_shreddit.py +51 -7
  226. package/skills/trends/scripts/lib/registers.py +134 -0
  227. package/skills/trends/scripts/lib/relevance.py +17 -0
  228. package/skills/trends/scripts/lib/render.py +2161 -271
  229. package/skills/trends/scripts/lib/rerank.py +623 -55
  230. package/skills/trends/scripts/lib/resolve.py +50 -1
  231. package/skills/trends/scripts/lib/safari_cookies.py +42 -15
  232. package/skills/trends/scripts/lib/schema.py +782 -1
  233. package/skills/trends/scripts/lib/setup_wizard.py +779 -120
  234. package/skills/trends/scripts/lib/signals.py +206 -7
  235. package/skills/trends/scripts/lib/stocktwits.py +374 -0
  236. package/skills/trends/scripts/lib/subproc.py +307 -53
  237. package/skills/trends/scripts/lib/techmeme.py +89 -64
  238. package/skills/trends/scripts/lib/telegram.py +317 -0
  239. package/skills/trends/scripts/lib/tiktok.py +13 -9
  240. package/skills/trends/scripts/lib/topic_shape.py +340 -0
  241. package/skills/trends/scripts/lib/transcribe.py +12 -3
  242. package/skills/trends/scripts/lib/trustpilot.py +256 -5
  243. package/skills/trends/scripts/lib/truthsocial.py +9 -1
  244. package/skills/trends/scripts/lib/ui.py +6 -0
  245. package/skills/trends/scripts/lib/usage.py +132 -0
  246. package/skills/trends/scripts/lib/web_search_keyless.py +63 -4
  247. package/skills/trends/scripts/lib/windows_job.py +138 -0
  248. package/skills/trends/scripts/lib/x_api.py +824 -0
  249. package/skills/trends/scripts/lib/x_envelope.py +733 -0
  250. package/skills/trends/scripts/lib/x_judge.py +292 -0
  251. package/skills/trends/scripts/lib/xai_x.py +26 -3
  252. package/skills/trends/scripts/lib/xquik.py +89 -15
  253. package/skills/trends/scripts/lib/xurl_x.py +284 -91
  254. package/skills/trends/scripts/lib/youtube_yt.py +570 -119
  255. package/skills/trends/scripts/setup-keychain.sh +12 -4
  256. package/skills/trends/scripts/setup-pass.sh +3 -0
  257. package/skills/trends/scripts/store.py +444 -50
  258. package/skills/trends/scripts/watchlist.py +38 -11
  259. package/skills/trends/scripts/x_lane_parity.py +107 -0
  260. package/skills/video-studio/CHANGELOG.md +4 -0
  261. package/skills/video-studio/SKILL.md +4 -4
  262. package/skills/video-studio/references/styles/product-flight.md +45 -0
  263. package/skills/video-studio/references/styles.md +3 -1
  264. package/skills/video-studio-motion/ATTRIBUTION.md +1 -1
  265. package/skills/video-studio-motion/CHANGELOG.md +4 -0
  266. package/skills/video-studio-motion/SKILL.md +8 -5
  267. package/skills/youtube-planner/ATTRIBUTION.md +25 -0
  268. package/skills/youtube-planner/CHANGELOG.md +12 -0
  269. package/skills/youtube-planner/SKILL.md +191 -0
  270. package/skills/youtube-planner/references/plan-template.md +57 -0
  271. package/skills/youtube-planner/references/stages.md +119 -0
  272. package/skills/youtube-planner/scripts/floor_ceiling.py +132 -0
  273. package/skills/coder-brainstorming/spec-document-reviewer-prompt.md +0 -49
  274. package/skills/trends/scripts/lib/reddit_rss.py +0 -224
@@ -0,0 +1,769 @@
1
+ """Backend-chain descriptors with predicted selection (doctor, R4).
2
+
3
+ Chained sources declare their routing here ONCE — imported from the
4
+ definitions ``lib/env.py`` already owns (chain order, pin var names) — and
5
+ ``resolve()`` turns side-effect-free probes into a truthful prediction of
6
+ what the next run will do.
7
+
8
+ Two resolution modes:
9
+
10
+ - ``alternative`` (X, YouTube, web search): the pipeline tries genuinely
11
+ interchangeable backends in a declared order. Resolution probes ALL
12
+ candidates first, then picks (collect-then-pick): the first fully-usable
13
+ backend wins the "will use" prediction; otherwise the best degraded
14
+ candidate resolves with a warn tier; otherwise the source is an error
15
+ carrying the highest-priority backend's prescription. Collecting before
16
+ picking prevents an installed-but-unauthenticated preferred backend from
17
+ shadowing a fully working fallback.
18
+
19
+ - ``conditional`` (Reddit): routing is per-query and outcome-dependent —
20
+ public keyless composite by default, ScrapeCreators backfill only when
21
+ results fall below the configured thinness floor (see the gating in
22
+ ``lib/pipeline.py``). No probe can pick one winner, so resolution renders
23
+ honest conditional wording instead of an ``active_backend``. Reddit's
24
+ internal keyless lanes (search/listing/arctic/shreddit) are sub-probe detail
25
+ inside the public composite, never chain entries.
26
+
27
+ ``active_backend`` semantics: a PREDICTION — "the first backend the probes
28
+ say the next run will try" — rendered as "will use". It is not an
29
+ observation of what served a past run, and runtime failover can still
30
+ diverge mid-run (a present-but-expired paid key passes a presence probe).
31
+
32
+ Paid lanes (xai, xapi, xquik, serper, and every other API-key backend,
33
+ including ScrapeCreators) probe KEY PRESENCE ONLY: a dict lookup, never a
34
+ network call or credential spend. Binary-backed lanes reuse the U1 dependency
35
+ probe layer (``health.probe_dependency``) so a stale shim reads as BROKEN,
36
+ not available (#692).
37
+
38
+ This module observes and predicts only. It must never alter which backend
39
+ the pipeline actually uses; parity with the pipeline's pre-failover
40
+ selection is asserted in ``tests/test_backend_descriptors.py``. The X chain
41
+ is shaped by ``env.x_policy``: on an official-only host the findings and
42
+ the chain carry only the policy's backends (plus a pinned one), so doctor
43
+ JSON never names a non-official backend there unless it is pinned.
44
+ """
45
+
46
+ from __future__ import annotations
47
+
48
+ from dataclasses import dataclass
49
+ from shutil import which
50
+ from typing import Any, Callable, Dict, List, Optional, Tuple
51
+
52
+ from . import env, health, prescriptions
53
+
54
+ # Resolution modes.
55
+ MODE_ALTERNATIVE = "alternative" # probe-ordered chain, first-usable wins
56
+ MODE_CONDITIONAL = "conditional" # per-query routing; wording, never a winner
57
+
58
+ # Rollup tiers for a resolved chain (doctor maps these into its R1 table).
59
+ TIER_OK = "ok"
60
+ TIER_WARN = "warn"
61
+ TIER_ERROR = "error"
62
+
63
+ # Web search backend order. grounding.web_search's auto branch owns the
64
+ # runtime behavior (brave -> exa -> serper -> parallel -> keyless floor);
65
+ # there is no importable constant there, so this declaration is guarded by
66
+ # the grounding-auto parity test rather than an import.
67
+ WEB_BACKEND_ORDER: Tuple[str, ...] = ("brave", "exa", "serper", "parallel", "keyless")
68
+
69
+ # YouTube backend order (pipeline: yt-dlp first, ScrapeCreators search
70
+ # fallback when yt-dlp is absent or fails — see lib/pipeline.py).
71
+ YOUTUBE_BACKEND_ORDER: Tuple[str, ...] = ("yt-dlp", "scrapecreators")
72
+
73
+ # Chain-failure fixes embed the registry's CLI forms (KTD 7): the command a
74
+ # backend finding prescribes and the one doctor/quality-nudge render for the
75
+ # same failure mode come from one entry and cannot drift.
76
+ _SC_PRESCRIPTION = (
77
+ "set SCRAPECREATORS_API_KEY (free 10,000-call signup: "
78
+ f"{prescriptions.get('scrapecreators', 'key_missing').fix_cli})"
79
+ )
80
+
81
+
82
+ def _x_cookies_prescription(config: Dict[str, Any]) -> str:
83
+ """Bird's unconfigured fix, routed through the X policy.
84
+
85
+ Off an official-only host this is the cookie-consent command; on one
86
+ the same lookup yields the official-path entry (connector lane, bearer,
87
+ xAI key), so a pinned-but-unconfigured scraper never prescribes a
88
+ cookie read there.
89
+ """
90
+ entry = prescriptions.for_x(config, "cookies_missing")
91
+ if entry.failure == "cookies_missing":
92
+ return f"run setup with browser-cookie consent: {entry.fix_cli}"
93
+ return f"{entry.fix_nl} (cli: {entry.fix_cli})"
94
+
95
+
96
+ @dataclass
97
+ class BackendFinding:
98
+ """Side-effect-free probe outcome for one backend of a chained source.
99
+
100
+ ``status`` uses the ``lib.health`` vocabulary (OK/DEGRADED/MISSING/
101
+ BROKEN/TIMEOUT/ERROR). ``prescription`` is the fix when non-OK.
102
+ ``requires`` is the backend's requirement note for report rendering.
103
+ """
104
+
105
+ name: str
106
+ status: str
107
+ detail: str = ""
108
+ prescription: str = ""
109
+ requires: str = ""
110
+
111
+ @property
112
+ def usable(self) -> bool:
113
+ """Fully or partially usable (OK/DEGRADED) — eligible for selection."""
114
+ return self.status in (health.OK, health.DEGRADED)
115
+
116
+
117
+ @dataclass(frozen=True)
118
+ class BackendSpec:
119
+ """One backend in a chain: name, probe, requirement note, paid flag.
120
+
121
+ ``probe`` must be side-effect-free. When ``paid`` is True the probe is
122
+ key-presence only: no subprocess, no network, no credential spend.
123
+ ``opt_in`` marks backends that are never auto-selected and require an
124
+ explicit pin (grok).
125
+ """
126
+
127
+ name: str
128
+ requires: str
129
+ probe: Callable[[Dict[str, Any]], "BackendFinding"]
130
+ paid: bool = False
131
+ opt_in: bool = False
132
+
133
+
134
+ @dataclass(frozen=True)
135
+ class ChainDescriptor:
136
+ """A chained source's declared routing: backends, mode, and pin knob."""
137
+
138
+ source: str
139
+ mode: str
140
+ backends: Tuple[BackendSpec, ...]
141
+ pin_var: Optional[str] = None # env var pin (X, Reddit)
142
+ pin_flag: Optional[str] = None # CLI flag pin (web: --web-backend)
143
+
144
+
145
+ @dataclass
146
+ class BackendResolution:
147
+ """Resolved routing for one chained source.
148
+
149
+ ``active_backend`` is the will-use PREDICTION for alternative chains
150
+ and always None for conditional mode (Reddit never gets a computed
151
+ winner — ``conditional`` carries the honest wording instead).
152
+ """
153
+
154
+ source: str
155
+ mode: str
156
+ chain: List[str]
157
+ findings: List[BackendFinding]
158
+ active_backend: Optional[str] = None
159
+ tier: str = TIER_OK
160
+ pinned: bool = False
161
+ pin: Optional[str] = None
162
+ prescription: str = ""
163
+ conditional: str = ""
164
+
165
+ @property
166
+ def summary(self) -> str:
167
+ """One-line rendering: will-use prediction or conditional wording."""
168
+ if self.mode == MODE_CONDITIONAL:
169
+ return self.conditional
170
+ if self.active_backend is None:
171
+ line = f"no usable backend (chain: {' -> '.join(self.chain)})"
172
+ if self.prescription:
173
+ line += f"; fix: {self.prescription}"
174
+ return line
175
+ line = f"will use: {self.active_backend}"
176
+ if self.pinned:
177
+ line += f" (pinned via {self._pin_origin()})"
178
+ return line
179
+
180
+ def _pin_origin(self) -> str:
181
+ d = DESCRIPTORS.get(self.source)
182
+ if d is None:
183
+ return "pin"
184
+ return d.pin_var or d.pin_flag or "pin"
185
+
186
+
187
+ # ---------------------------------------------------------------------------
188
+ # Probes. All side-effect-free; paid lanes are pure dict lookups.
189
+ # ---------------------------------------------------------------------------
190
+
191
+ def _key_probe(name: str, key_var: str, requires: str, note: str = "") -> Callable:
192
+ """Key-presence probe for a paid API lane. Never touches the network."""
193
+
194
+ def probe(config: Dict[str, Any]) -> BackendFinding:
195
+ if config.get(key_var):
196
+ return BackendFinding(
197
+ name=name,
198
+ status=health.OK,
199
+ detail=f"{key_var} present",
200
+ requires=requires,
201
+ )
202
+ prescription = note or f"set {key_var} in ~/.config/last30days/.env"
203
+ return BackendFinding(
204
+ name=name,
205
+ status=health.MISSING,
206
+ detail=f"{key_var} not set",
207
+ prescription=prescription,
208
+ requires=requires,
209
+ )
210
+
211
+ return probe
212
+
213
+
214
+ def _probe_bird(config: Dict[str, Any]) -> BackendFinding:
215
+ """Bird = vendored X GraphQL client (node script) + browser-cookie creds.
216
+
217
+ Cookie presence is checked FIRST, mirroring ``env._x_backend_available``'s
218
+ gating (``has_bird_creds and is_bird_installed()``): without cookies bird
219
+ is unconfigured regardless of node/script state, and the fix is the
220
+ cookie-consent flow — a broken node runtime must not turn an unconfigured
221
+ backend into an error carrying a node prescription.
222
+ """
223
+ from . import bird_x
224
+
225
+ requires = "X browser cookies (AUTH_TOKEN/CT0) + node"
226
+ if not (config.get("AUTH_TOKEN") and config.get("CT0")):
227
+ return BackendFinding(
228
+ name="bird",
229
+ status=health.MISSING,
230
+ detail="X browser cookies (AUTH_TOKEN/CT0) not configured",
231
+ prescription=_x_cookies_prescription(config),
232
+ requires=requires,
233
+ )
234
+ if not bird_x.is_bird_installed():
235
+ # Distinguish a missing/broken node runtime from a missing script.
236
+ node = health.probe_dependency("node")
237
+ if node.status != health.OK:
238
+ return BackendFinding(
239
+ name="bird",
240
+ status=node.status,
241
+ detail=node.detail,
242
+ prescription=node.prescription,
243
+ requires=requires,
244
+ )
245
+ return BackendFinding(
246
+ name="bird",
247
+ status=health.MISSING,
248
+ detail="vendored bird-search client not found",
249
+ prescription="reinstall the skill (npx skills add . -g -y) to restore lib/vendor/bird-search",
250
+ requires=requires,
251
+ )
252
+ node = health.probe_dependency("node")
253
+ if node.status != health.OK:
254
+ # Resolvable-but-broken node (stale shim) must not read as usable.
255
+ return BackendFinding(
256
+ name="bird",
257
+ status=node.status,
258
+ detail=node.detail,
259
+ prescription=node.prescription,
260
+ requires=requires,
261
+ )
262
+ return BackendFinding(
263
+ name="bird",
264
+ status=health.OK,
265
+ detail="browser-cookie auth (AUTH_TOKEN/CT0) configured",
266
+ requires=requires,
267
+ )
268
+
269
+
270
+ def _probe_grok(config: Dict[str, Any]) -> BackendFinding:
271
+ """grok CLI = keyless X. LOCAL-ONLY probe, like _probe_xurl.
272
+
273
+ Deliberately does NOT call ``health.probe_dependency``: that helper runs
274
+ ``subprocess.run([name, "--version"])``, and the whole-doctor-path test
275
+ patches ``subprocess.run`` to raise.
276
+
277
+ Consequence to be honest about: a grok binary that resolves on PATH but
278
+ will not execute (the stale-shim class) reports OK here and fails only when
279
+ a real run shells out. ``grok_x.is_available`` does not close that gap
280
+ either -- it is also filesystem-only. ``health.probe_dependency("grok")``
281
+ is the executing probe, and it runs in doctor's CLI-health block rather
282
+ than on this no-subprocess path.
283
+ """
284
+ from . import grok_x
285
+
286
+ requires = "grok CLI installed + signed in (no X credential)"
287
+ if which("grok") is None:
288
+ off_path = health._off_path_binary("grok")
289
+ if off_path is not None:
290
+ return BackendFinding(
291
+ name="grok",
292
+ status=health.MISSING,
293
+ requires=requires,
294
+ detail=f"grok is installed at {off_path} but that directory is not on this process's PATH",
295
+ prescription=f'add {off_path.parent} to PATH (e.g. export PATH="{off_path.parent}:$PATH")',
296
+ )
297
+ return BackendFinding(
298
+ name="grok",
299
+ status=health.MISSING,
300
+ requires=requires,
301
+ detail="grok CLI not found on PATH",
302
+ prescription=(
303
+ "install the Grok CLI: curl -fsSL https://x.ai/cli/install.sh | bash, "
304
+ "then run `grok login`"
305
+ ),
306
+ )
307
+ store_status, store_detail, expires_at = grok_x.stored_auth_status()
308
+ if store_status == grok_x.AUTH_OK:
309
+ return BackendFinding(
310
+ name="grok",
311
+ status=health.OK,
312
+ requires=requires,
313
+ detail=f"{store_detail} (not live-verified until a run)",
314
+ )
315
+ if store_status == grok_x.AUTH_EXPIRED:
316
+ expiry_str = expires_at.isoformat() if expires_at else "unknown"
317
+ return BackendFinding(
318
+ name="grok",
319
+ status=health.DEGRADED,
320
+ requires=requires,
321
+ detail=(
322
+ f"Grok session expired at {expiry_str}; "
323
+ "refresh happens at run time (if revoked, run `grok login --device-auth`)"
324
+ ),
325
+ prescription="grok login --device-auth",
326
+ )
327
+ if store_status == grok_x.AUTH_ERROR:
328
+ return BackendFinding(
329
+ name="grok",
330
+ status=health.ERROR,
331
+ requires=requires,
332
+ detail=store_detail,
333
+ prescription="grok login",
334
+ )
335
+ return BackendFinding(
336
+ name="grok",
337
+ status=health.MISSING,
338
+ requires=requires,
339
+ detail="grok CLI installed but not signed in",
340
+ prescription="grok login",
341
+ )
342
+
343
+
344
+ def _probe_xapi(config: Dict[str, Any]) -> BackendFinding:
345
+ """xapi = direct X API v2 with an app-only bearer. KEY PRESENCE ONLY.
346
+
347
+ Never a network call. The unconfigured fix is the official-path
348
+ prescription on an official-only host (connector lane, bearer, xAI key,
349
+ with the about-a-week caveat); elsewhere the plain key hint,
350
+ since xapi runs there only under an explicit pin.
351
+ """
352
+ requires = "X_BEARER_TOKEN (X API v2)"
353
+ if config.get("X_BEARER_TOKEN"):
354
+ return BackendFinding(
355
+ name="xapi",
356
+ status=health.OK,
357
+ detail="X_BEARER_TOKEN present",
358
+ requires=requires,
359
+ )
360
+ if env.x_policy(config).official_only:
361
+ entry = prescriptions.get("x", "bearer_missing")
362
+ prescription = f"{entry.fix_nl} (cli: {entry.fix_cli})"
363
+ else:
364
+ prescription = "set X_BEARER_TOKEN in ~/.config/last30days/.env"
365
+ return BackendFinding(
366
+ name="xapi",
367
+ status=health.MISSING,
368
+ detail="X_BEARER_TOKEN not set",
369
+ prescription=prescription,
370
+ requires=requires,
371
+ )
372
+
373
+
374
+ def _probe_xurl(config: Dict[str, Any]) -> BackendFinding:
375
+ """xurl = official X API v2 CLI (OAuth2). Free lane; LOCAL-ONLY probe.
376
+
377
+ Doctor's no-network guarantee forbids the live ``xurl whoami`` check
378
+ (``xurl_x.is_available()`` — an authenticated X API call, reserved for
379
+ research time). This probe keys on local evidence instead: the binary
380
+ on PATH plus xurl's on-disk token store (~/.xurl). Stored credentials
381
+ read as OK with an explicit "not live-verified" caveat; an unreadable
382
+ token store is a typed ERROR (broken, not unconfigured).
383
+ """
384
+ from . import xurl_x
385
+
386
+ requires = "xurl CLI installed + OAuth2 login"
387
+ if which("xurl") is None:
388
+ return BackendFinding(
389
+ name="xurl",
390
+ status=health.MISSING,
391
+ detail="xurl CLI not found on PATH",
392
+ prescription="npm install -g xurl && xurl auth oauth2 login",
393
+ requires=requires,
394
+ )
395
+ store_status, store_detail = xurl_x.stored_auth_status()
396
+ if store_status == xurl_x.AUTH_OK:
397
+ return BackendFinding(
398
+ name="xurl",
399
+ status=health.OK,
400
+ detail=(
401
+ "installed; stored OAuth2 credentials present; "
402
+ "auth not live-verified (no network)"
403
+ ),
404
+ requires=requires,
405
+ )
406
+ if store_status == xurl_x.AUTH_ERROR:
407
+ return BackendFinding(
408
+ name="xurl",
409
+ status=health.ERROR,
410
+ detail=store_detail,
411
+ prescription="xurl auth oauth2 login",
412
+ requires=requires,
413
+ )
414
+ return BackendFinding(
415
+ name="xurl",
416
+ status=health.MISSING,
417
+ detail="xurl installed but not authenticated",
418
+ prescription="xurl auth oauth2 login",
419
+ requires=requires,
420
+ )
421
+
422
+
423
+ def _probe_ytdlp(config: Dict[str, Any]) -> BackendFinding:
424
+ """yt-dlp via the U1 dependency-probe layer (missing/broken/timeout)."""
425
+ dep = health.probe_dependency("yt-dlp")
426
+ return BackendFinding(
427
+ name="yt-dlp",
428
+ status=dep.status,
429
+ detail=dep.detail,
430
+ prescription=dep.prescription,
431
+ requires="yt-dlp on the agent-subprocess PATH",
432
+ )
433
+
434
+
435
+ def _probe_web_keyless(config: Dict[str, Any]) -> BackendFinding:
436
+ """The keyless web-search floor: works keyless, but degraded quality."""
437
+ requires = "no key; suppressed on native-search hosts"
438
+ if env.keyless_web_allowed(config):
439
+ return BackendFinding(
440
+ name="keyless",
441
+ status=health.DEGRADED,
442
+ detail="keyless search floor (no paid key; lower quality)",
443
+ requires=requires,
444
+ )
445
+ return BackendFinding(
446
+ name="keyless",
447
+ status=health.MISSING,
448
+ detail="keyless floor suppressed: host has native web search",
449
+ prescription="",
450
+ requires=requires,
451
+ )
452
+
453
+
454
+ def _probe_reddit_public(config: Dict[str, Any]) -> BackendFinding:
455
+ """Public keyless Reddit composite; internal lanes are sub-probe detail."""
456
+ return BackendFinding(
457
+ name="public",
458
+ status=health.OK,
459
+ detail="public keyless composite (lanes: search, listing, arctic, shreddit)",
460
+ requires="none (public endpoints)",
461
+ )
462
+
463
+
464
+ # ---------------------------------------------------------------------------
465
+ # Registry: routing declared once, from env.py's definitions where they exist.
466
+ # ---------------------------------------------------------------------------
467
+
468
+ _X_PROBES: Dict[str, Callable[[Dict[str, Any]], BackendFinding]] = {
469
+ "xai": _key_probe("xai", "XAI_API_KEY", "XAI_API_KEY (xAI/Grok live search)"),
470
+ "grok": _probe_grok,
471
+ "bird": _probe_bird,
472
+ "xurl": _probe_xurl,
473
+ "xquik": _key_probe("xquik", "XQUIK_API_KEY", "XQUIK_API_KEY (xquik.com)"),
474
+ # Direct X API v2 with an app-only bearer: key presence only, no network.
475
+ "xapi": _probe_xapi,
476
+ }
477
+ _X_PAID = {"xai", "xquik", "xapi"}
478
+ # Opt-in backends: never auto-selected; require explicit pin.
479
+ _X_OPT_IN = set(env.X_BACKEND_OPT_IN)
480
+
481
+ _WEB_PROBES: Dict[str, Callable[[Dict[str, Any]], BackendFinding]] = {
482
+ "brave": _key_probe("brave", "BRAVE_API_KEY", "BRAVE_API_KEY"),
483
+ "exa": _key_probe("exa", "EXA_API_KEY", "EXA_API_KEY"),
484
+ "serper": _key_probe("serper", "SERPER_API_KEY", "SERPER_API_KEY"),
485
+ "parallel": _key_probe("parallel", "PARALLEL_API_KEY", "PARALLEL_API_KEY"),
486
+ "keyless": _probe_web_keyless,
487
+ }
488
+ _WEB_KEYED = {"brave", "exa", "serper", "parallel"}
489
+
490
+ _SC_SPEC = BackendSpec(
491
+ name="scrapecreators",
492
+ requires="SCRAPECREATORS_API_KEY",
493
+ probe=_key_probe(
494
+ "scrapecreators", "SCRAPECREATORS_API_KEY", "SCRAPECREATORS_API_KEY",
495
+ note=_SC_PRESCRIPTION,
496
+ ),
497
+ paid=True,
498
+ )
499
+
500
+ # X backend requirements, keyed by name.
501
+ _X_REQUIRES: Dict[str, str] = {
502
+ "xai": "XAI_API_KEY (xAI/Grok live search)",
503
+ "grok": "grok CLI installed + signed in (opt-in only; pin to enable)",
504
+ "bird": "X browser cookies (AUTH_TOKEN/CT0) + node",
505
+ "xurl": "xurl CLI installed + OAuth2 login",
506
+ "xquik": "XQUIK_API_KEY (xquik.com)",
507
+ "xapi": "X_BEARER_TOKEN (X API v2)",
508
+ }
509
+
510
+ DESCRIPTORS: Dict[str, ChainDescriptor] = {
511
+ # X: chain order and pin var imported from env.py (single source of truth).
512
+ # Backends include the auto chain (X_BACKEND_ORDER) plus opt-in entries
513
+ # (X_BACKEND_OPT_IN) for doctor visibility. Opt-in backends like grok
514
+ # appear in findings but are never auto-selected; pin to enable.
515
+ "x": ChainDescriptor(
516
+ source="x",
517
+ mode=MODE_ALTERNATIVE,
518
+ backends=tuple(
519
+ BackendSpec(
520
+ name=name,
521
+ requires=_X_REQUIRES[name],
522
+ probe=_X_PROBES[name],
523
+ paid=name in _X_PAID,
524
+ opt_in=name in _X_OPT_IN,
525
+ )
526
+ for name in env.X_BACKEND_ORDER + env.X_BACKEND_OPT_IN
527
+ ),
528
+ pin_var=env.X_BACKEND_PIN_VAR,
529
+ ),
530
+ "youtube": ChainDescriptor(
531
+ source="youtube",
532
+ mode=MODE_ALTERNATIVE,
533
+ backends=(
534
+ BackendSpec(
535
+ name="yt-dlp",
536
+ requires="yt-dlp on the agent-subprocess PATH",
537
+ probe=_probe_ytdlp,
538
+ ),
539
+ _SC_SPEC,
540
+ ),
541
+ pin_var=None, # no YouTube pin knob exists
542
+ ),
543
+ "web": ChainDescriptor(
544
+ source="web",
545
+ mode=MODE_ALTERNATIVE,
546
+ backends=tuple(
547
+ BackendSpec(
548
+ name=name,
549
+ requires=(f"{name.upper()}_API_KEY" if name in _WEB_KEYED
550
+ else "no key; suppressed on native-search hosts"),
551
+ probe=_WEB_PROBES[name],
552
+ paid=name in _WEB_KEYED,
553
+ )
554
+ for name in WEB_BACKEND_ORDER
555
+ ),
556
+ pin_var=None, # pinned per-run via --web-backend, not an env var
557
+ pin_flag="--web-backend",
558
+ ),
559
+ "reddit": ChainDescriptor(
560
+ source="reddit",
561
+ mode=MODE_CONDITIONAL,
562
+ backends=(
563
+ BackendSpec(
564
+ name="public",
565
+ requires="none (public endpoints)",
566
+ probe=_probe_reddit_public,
567
+ ),
568
+ _SC_SPEC,
569
+ ),
570
+ pin_var=env.REDDIT_BACKEND_PIN_VAR,
571
+ ),
572
+ }
573
+
574
+
575
+ def get_descriptor(source: str) -> ChainDescriptor:
576
+ """Return the declared routing descriptor for ``source`` (KeyError if none)."""
577
+ return DESCRIPTORS[source]
578
+
579
+
580
+ # ---------------------------------------------------------------------------
581
+ # Resolution
582
+ # ---------------------------------------------------------------------------
583
+
584
+ def resolve(
585
+ source: str,
586
+ config: Dict[str, Any],
587
+ pin: Optional[str] = None,
588
+ ) -> BackendResolution:
589
+ """Resolve a chained source's routing into a truthful prediction.
590
+
591
+ ``pin`` is an explicit per-run pin (the ``--web-backend`` flag); it
592
+ takes precedence over the descriptor's env pin var. ``"auto"``/None
593
+ mean unpinned. Probing is side-effect-free and collect-then-pick.
594
+
595
+ Time budget: backends are probed sequentially, so a chain's budget is
596
+ ADDITIVE across its backends — each binary-backed probe is bounded by
597
+ ``health.PROBE_TIMEOUT`` and paid/key lanes are dict lookups that cost
598
+ nothing, giving a worst case of roughly (binary probes in the chain) x
599
+ ``health.PROBE_TIMEOUT``. Deliberately no intra-chain concurrency:
600
+ probes are memoized per process and the worst case only occurs when
601
+ multiple binaries are simultaneously hung.
602
+ """
603
+ descriptor = get_descriptor(source)
604
+ specs, auto_names = _specs_for_policy(descriptor, config)
605
+ findings = [_run_probe(spec, config) for spec in specs]
606
+ if descriptor.mode == MODE_CONDITIONAL:
607
+ return _resolve_conditional(descriptor, config, findings)
608
+ return _resolve_alternative(descriptor, config, findings, pin, auto_names)
609
+
610
+
611
+ def _specs_for_policy(
612
+ descriptor: ChainDescriptor,
613
+ config: Dict[str, Any],
614
+ ) -> Tuple[List[BackendSpec], set]:
615
+ """The backends to probe for this host, and which of them auto-select.
616
+
617
+ Every source except X keeps its declared backends; auto-selection is the
618
+ non-opt-in set. For X the answer comes from ``env.x_policy``: on a
619
+ default host the declared chain (auto order plus opt-in entries for
620
+ doctor visibility) is unchanged; on an official-only host the findings
621
+ are the policy's chain in its order, plus the pinned backend when the
622
+ pin names something outside it, so neither doctor JSON nor the chain
623
+ string carries a non-official backend unless it is pinned. Observation
624
+ only: this mirrors ``env.x_backend_chain``, it never alters it.
625
+ """
626
+ specs = list(descriptor.backends)
627
+ if descriptor.source != "x":
628
+ return specs, {spec.name for spec in specs if not spec.opt_in}
629
+ policy = env.x_policy(config)
630
+ auto_names = set(policy.auto_chain)
631
+ if not policy.official_only:
632
+ return specs, auto_names
633
+ by_name = {spec.name: spec for spec in specs}
634
+ ordered = [by_name[name] for name in policy.auto_chain if name in by_name]
635
+ pin = env.x_backend_pin(config)
636
+ if pin in by_name and pin not in auto_names:
637
+ ordered.append(by_name[pin])
638
+ return ordered, auto_names
639
+
640
+
641
+ def _run_probe(spec: BackendSpec, config: Dict[str, Any]) -> BackendFinding:
642
+ """Run one probe, isolating failures so one bad probe can't blank a chain."""
643
+ try:
644
+ finding = spec.probe(config)
645
+ except Exception as exc: # a probe bug must not take the report down
646
+ finding = BackendFinding(
647
+ name=spec.name,
648
+ status=health.ERROR,
649
+ detail=f"probe failed: {type(exc).__name__}: {exc}",
650
+ requires=spec.requires,
651
+ )
652
+ if not finding.requires:
653
+ finding.requires = spec.requires
654
+ return finding
655
+
656
+
657
+ def _resolve_alternative(
658
+ descriptor: ChainDescriptor,
659
+ config: Dict[str, Any],
660
+ findings: List[BackendFinding],
661
+ pin: Optional[str],
662
+ auto_names: Optional[set] = None,
663
+ ) -> BackendResolution:
664
+ names = [f.name for f in findings]
665
+ by_name = {f.name: f for f in findings}
666
+ if auto_names is None:
667
+ auto_names = {spec.name for spec in descriptor.backends if not spec.opt_in}
668
+ # Backends outside the auto set are opt-in here (never auto-selected).
669
+ opt_in_names = {name for name in names if name not in auto_names}
670
+ res = BackendResolution(
671
+ source=descriptor.source,
672
+ mode=MODE_ALTERNATIVE,
673
+ chain=list(names),
674
+ findings=findings,
675
+ )
676
+
677
+ pin_name: Optional[str] = None
678
+ if pin and pin not in ("auto", "none") and pin in by_name:
679
+ pin_name = pin
680
+ elif descriptor.pin_var:
681
+ raw = (config.get(descriptor.pin_var) or "").lower()
682
+ if raw in by_name:
683
+ pin_name = raw
684
+
685
+ if pin_name:
686
+ # A pin forces a single backend (no failover) — mirror
687
+ # env.x_backend_chain's pin semantics exactly.
688
+ res.pinned = True
689
+ res.pin = pin_name
690
+ finding = by_name[pin_name]
691
+ if finding.status == health.OK:
692
+ res.active_backend = pin_name
693
+ res.tier = TIER_OK
694
+ elif finding.status == health.DEGRADED:
695
+ res.active_backend = pin_name
696
+ res.tier = TIER_WARN
697
+ else:
698
+ res.tier = TIER_ERROR
699
+ res.prescription = finding.prescription or (
700
+ f"unpin {descriptor.pin_var or descriptor.pin_flag} or fix {pin_name}"
701
+ )
702
+ return res
703
+
704
+ # Collect-then-pick: first fully-usable wins; else best degraded; else
705
+ # error carrying the highest-priority backend's prescription.
706
+ # Opt-in backends are NEVER auto-selected; skip them entirely.
707
+ auto_findings = [f for f in findings if f.name not in opt_in_names]
708
+ for finding in auto_findings:
709
+ if finding.status == health.OK:
710
+ res.active_backend = finding.name
711
+ res.tier = TIER_OK
712
+ return res
713
+ for finding in auto_findings:
714
+ if finding.status == health.DEGRADED:
715
+ res.active_backend = finding.name
716
+ res.tier = TIER_WARN
717
+ return res
718
+ res.tier = TIER_ERROR
719
+ # Prescription comes from the first auto-chain backend, not opt-in.
720
+ res.prescription = auto_findings[0].prescription if auto_findings else ""
721
+ return res
722
+
723
+
724
+ def _resolve_conditional(
725
+ descriptor: ChainDescriptor,
726
+ config: Dict[str, Any],
727
+ findings: List[BackendFinding],
728
+ ) -> BackendResolution:
729
+ """Reddit: render the real per-query semantics, never a computed winner."""
730
+ res = BackendResolution(
731
+ source=descriptor.source,
732
+ mode=MODE_CONDITIONAL,
733
+ chain=[spec.name for spec in descriptor.backends],
734
+ findings=findings,
735
+ active_backend=None, # conditional mode never picks a winner
736
+ tier=TIER_OK, # the public keyless composite is always reachable
737
+ )
738
+ has_key = bool(config.get("SCRAPECREATORS_API_KEY"))
739
+ raw_pin = (config.get(descriptor.pin_var) or "").lower() if descriptor.pin_var else ""
740
+ pinned_sc = has_key and raw_pin == "scrapecreators"
741
+ floor = env.reddit_sc_min_items(config)
742
+
743
+ if pinned_sc:
744
+ res.pinned = True
745
+ res.pin = "scrapecreators"
746
+ res.conditional = (
747
+ f"ScrapeCreators primary (pinned via {descriptor.pin_var}); "
748
+ "public keyless composite fallback"
749
+ )
750
+ return res
751
+
752
+ if has_key:
753
+ if floor > 0:
754
+ backfill = (
755
+ f"ScrapeCreators backfill when results fall below the "
756
+ f"{floor}-item floor"
757
+ )
758
+ else:
759
+ backfill = "ScrapeCreators backfill when the free path returns nothing"
760
+ res.conditional = f"public keyless composite (default); {backfill}"
761
+ return res
762
+
763
+ res.conditional = "public keyless composite (default); no ScrapeCreators key for backfill"
764
+ if raw_pin == "scrapecreators":
765
+ # The pipeline ignores the pin without a key; say so honestly.
766
+ res.conditional += (
767
+ f" ({descriptor.pin_var} pin ignored: SCRAPECREATORS_API_KEY not set)"
768
+ )
769
+ return res