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,2166 @@
1
+ """Unified `doctor` health surface: aggregate, tier rollup, render (U4).
2
+
3
+ One command answers "what's broken, what's serving, and what do I run to
4
+ fix it" by composing the existing health layers instead of replacing them:
5
+
6
+ - U1 ``lib/health.py`` dependency probes (ok/missing/broken/timeout)
7
+ - U2 ``lib/backends.py`` chain descriptors + "will use" prediction
8
+ - U3 ``lib/prescriptions.py`` the single remediation vocabulary
9
+ - ``lib/pipeline.diagnose`` + ``lib/permission_preflight`` for the
10
+ engine-level availability and permission summary
11
+
12
+ The legacy ``--diagnose`` / ``--preflight`` flags keep their frozen JSON
13
+ shapes (see ``tests/test_diagnose_compat.py``); anything new appears ONLY
14
+ in ``doctor --json``.
15
+
16
+ Tier rollup (the R1 machine contract). Per source, ``tier`` is the
17
+ four-value rollup and ``status`` preserves the most specific state:
18
+
19
+ | condition | status | tier |
20
+ |------------------------------------------------------|---------------|-------|
21
+ | probes pass, credentials (if any) present | ok | ok |
22
+ | usable but degraded (fallback serving, partial) | degraded | warn |
23
+ | opt-in not enabled / key-gated unconfigured | opt-in / | off |
24
+ | | unconfigured | |
25
+ | configured but missing / broken / timeout / error | that status | error |
26
+
27
+ Semantics and guarantees:
28
+
29
+ - ``active_backend`` is a PREDICTION ("will use"), never an observation
30
+ (KTD 4). Reddit is conditional mode: honest wording, no single winner.
31
+ - On a native-search host with no web keys, engine-side web search is
32
+ intentionally off — doctor reports tier ``off`` with a host-native note,
33
+ never a false-alarm error. Web search has NO env pin, only the
34
+ ``--web-backend`` flag; the record says so.
35
+ - No cookie reads (plan-only, like ``--diagnose``); no secret values
36
+ anywhere — key presence is booleans only.
37
+ - Per-source exception isolation: one failing probe becomes that source's
38
+ ``error`` record; it can never blank the report.
39
+ - Reporting problems is a successful run: the exit code is always 0.
40
+ """
41
+
42
+ from __future__ import annotations
43
+
44
+ import concurrent.futures
45
+ import datetime
46
+ import hashlib
47
+ import json
48
+ import os
49
+ import shutil
50
+ import sys
51
+ import time
52
+ import urllib.error
53
+ import urllib.request
54
+ from pathlib import Path
55
+ from typing import Any, Callable, Dict, List, Optional
56
+
57
+ from . import backends, brightdata, env, health, http, prescriptions, reddit_search, x_api
58
+ from .backends import TIER_ERROR, TIER_OK, TIER_WARN
59
+
60
+ # Rollup tiers (R1). ok/warn/error are U2's; only "off" is doctor's own.
61
+ TIER_OFF = "off"
62
+
63
+ # Specific statuses and the rollup row each maps to. The doctor-local
64
+ # statuses "opt-in" and "unconfigured" have no health constant.
65
+ TIER_BY_STATUS: Dict[str, str] = {
66
+ health.OK: TIER_OK,
67
+ health.DEGRADED: TIER_WARN,
68
+ "opt-in": TIER_OFF,
69
+ "unconfigured": TIER_OFF,
70
+ health.MISSING: TIER_ERROR,
71
+ health.BROKEN: TIER_ERROR,
72
+ health.TIMEOUT: TIER_ERROR,
73
+ health.ERROR: TIER_ERROR,
74
+ }
75
+
76
+ # Tier -> glyph, still used by the cached-report shape validator to confirm a
77
+ # record carries a known tier. The user-facing render uses AUDIT_GLYPHS below.
78
+ GLYPHS = {TIER_OK: "✓", TIER_WARN: "!", TIER_OFF: "○", TIER_ERROR: "✗"}
79
+
80
+ # Four-state audit (R1). A presentation layer derived from the tier rollup +
81
+ # last-run evidence + optional live probe - it augments the per-source records,
82
+ # it does not replace them (the tier/status fields stay in the record and JSON).
83
+ AUDIT_WORKING = "working"
84
+ AUDIT_UNVERIFIED = "unverified"
85
+ AUDIT_NOT_WORKING = "not-working"
86
+ AUDIT_COULD_BE_ON = "could-be-on"
87
+
88
+ AUDIT_GLYPHS = {
89
+ AUDIT_WORKING: "●", # ●
90
+ AUDIT_UNVERIFIED: "◐", # ◐
91
+ AUDIT_NOT_WORKING: "✕", # ✕
92
+ AUDIT_COULD_BE_ON: "○", # ○
93
+ }
94
+
95
+ AUDIT_GROUPS = (
96
+ (AUDIT_WORKING, "WORKING"),
97
+ (AUDIT_UNVERIFIED, "TURNED ON - UNVERIFIED"),
98
+ (AUDIT_NOT_WORKING, "NOT WORKING"),
99
+ (AUDIT_COULD_BE_ON, "COULD BE ON"),
100
+ )
101
+
102
+ # Sources that need neither credentials nor a CLI: they always serve, so with
103
+ # no run evidence and no probe they are WORKING, not UNVERIFIED.
104
+ KEYLESS_ALWAYS_ON = frozenset(
105
+ {"reddit", "hackernews", "polymarket", "github", "library"}
106
+ )
107
+
108
+ # Fresh-run outcome states -> audit bucket for a tier-ok source. Anything not
109
+ # listed here (error / timeout / rate-limited / auth-failed / unreachable /
110
+ # schema-drift) means the source ran and failed -> NOT WORKING.
111
+ _RUN_WORKING_STATES = frozenset({health.OK, health.NO_RESULTS})
112
+ _RUN_UNVERIFIED_STATES = frozenset({health.PARTIAL, health.SKIPPED_UNCONFIGURED})
113
+
114
+
115
+ def audit_state(
116
+ name: str,
117
+ record: Dict[str, Any],
118
+ run_outcome: Optional[Dict[str, Any]] = None,
119
+ probe_result: Optional[Dict[str, Any]] = None,
120
+ ) -> str:
121
+ """Map a source's (tier, run evidence, probe) to one of four audit states.
122
+
123
+ Precedence: a failing/degraded tier is NOT WORKING regardless of history;
124
+ an off tier (opt-in / unconfigured) is COULD BE ON. For a tier-ok source,
125
+ fresh run evidence wins (ok/no-results -> WORKING; partial/skipped ->
126
+ UNVERIFIED; any error/timeout/rate-limit/etc -> NOT WORKING), then a live
127
+ probe, then the keyless-always-on fallback, else UNVERIFIED.
128
+ """
129
+ tier = record.get("tier")
130
+ if tier in (TIER_ERROR, TIER_WARN):
131
+ return AUDIT_NOT_WORKING
132
+ if tier == TIER_OFF:
133
+ return AUDIT_COULD_BE_ON
134
+ # tier ok
135
+ if run_outcome:
136
+ state = run_outcome.get("state")
137
+ if state in _RUN_WORKING_STATES:
138
+ return AUDIT_WORKING
139
+ if state in _RUN_UNVERIFIED_STATES:
140
+ return AUDIT_UNVERIFIED
141
+ return AUDIT_NOT_WORKING
142
+ if probe_result is not None:
143
+ if probe_result.get("ok"):
144
+ return AUDIT_WORKING
145
+ # A transient refusal (host rate-limited THIS probe) is not evidence the
146
+ # source is broken: the lane retries with backoff and serves fine. Report
147
+ # it as unverified instead of claiming a working source is down.
148
+ if probe_result.get("transient"):
149
+ return AUDIT_UNVERIFIED
150
+ return AUDIT_NOT_WORKING
151
+ if name in KEYLESS_ALWAYS_ON:
152
+ return AUDIT_WORKING
153
+ return AUDIT_UNVERIFIED
154
+
155
+ # Report order: chained sources first, then free, then key-gated/opt-in.
156
+ SOURCE_ORDER = (
157
+ "reddit",
158
+ "x",
159
+ "youtube",
160
+ "web",
161
+ "hackernews",
162
+ "polymarket",
163
+ "github",
164
+ "devto",
165
+ "digg",
166
+ "techmeme",
167
+ "arxiv",
168
+ "trustpilot",
169
+ "amazon",
170
+ "meta_ads",
171
+ "tiktok",
172
+ "instagram",
173
+ "threads",
174
+ "telegram",
175
+ "bluesky",
176
+ "truthsocial",
177
+ "perplexity",
178
+ "linkedin",
179
+ "pinterest",
180
+ "xiaohongshu",
181
+ "jobs",
182
+ "library",
183
+ )
184
+
185
+ # Sources whose availability depends on a downloaded CLI binary. doctor probes
186
+ # each (installed AND functional, via health.probe_dependency) and surfaces a
187
+ # per-source marker plus a dedicated CLI-health block (R2). Everything not
188
+ # listed is keyless - it needs no CLI. gh is OPTIONAL for GitHub (the REST tier
189
+ # works without it), so its absence is a note, never a failure.
190
+ CLI_DEPENDENCIES = {
191
+ "youtube": "yt-dlp",
192
+ "digg": "digg-pp-cli",
193
+ "techmeme": "techmeme-pp-cli",
194
+ "arxiv": "arxiv-pp-cli",
195
+ "trustpilot": "trustpilot-pp-cli",
196
+ # The only entry that also needs auth; _amazon_record reports the
197
+ # installed-but-unauthenticated state the shared CLI helper cannot.
198
+ "amazon": "brightdata",
199
+ "github": "gh",
200
+ }
201
+ _OPTIONAL_CLI_SOURCES = frozenset({"github"})
202
+
203
+ # Key-presence booleans for the setup block. NEVER values.
204
+ KEY_PRESENCE_VARS = (
205
+ "SCRAPECREATORS_API_KEY",
206
+ "XAI_API_KEY",
207
+ "XQUIK_API_KEY",
208
+ "X_BEARER_TOKEN",
209
+ "BRAVE_API_KEY",
210
+ "EXA_API_KEY",
211
+ "SERPER_API_KEY",
212
+ "PARALLEL_API_KEY",
213
+ "GROQ_API_KEY",
214
+ "OPENAI_API_KEY",
215
+ "GOOGLE_API_KEY",
216
+ "GEMINI_API_KEY",
217
+ "OPENROUTER_API_KEY",
218
+ "PERPLEXITY_API_KEY",
219
+ "GITHUB_TOKEN",
220
+ "TRUTHSOCIAL_TOKEN",
221
+ "BSKY_APP_PASSWORD",
222
+ )
223
+
224
+ # Failing statuses ranked most-specific-first for chained rollups: a broken
225
+ # shim outranks a timeout outranks a generic error when naming the source's
226
+ # status (all three roll up to tier error regardless).
227
+ _SPECIFIC_FAILURES = (health.BROKEN, health.TIMEOUT, health.ERROR)
228
+
229
+
230
+ def _fix_text(entry: prescriptions.Prescription) -> str:
231
+ """Render a registry entry as one actionable fix line (NL + CLI forms)."""
232
+ if entry.fix_cli and entry.fix_cli not in entry.fix_nl:
233
+ return f"{entry.fix_nl} (cli: {entry.fix_cli})"
234
+ return entry.fix_nl
235
+
236
+
237
+ def _record(
238
+ *,
239
+ status: str,
240
+ mode: str = "single",
241
+ backends_list: Optional[List[Dict[str, Any]]] = None,
242
+ active_backend: Optional[str] = None,
243
+ fix: str = "",
244
+ requires: str = "",
245
+ note: str = "",
246
+ detail: str = "",
247
+ pin_var: Optional[str] = None,
248
+ pin_flag: Optional[str] = None,
249
+ pinned: bool = False,
250
+ ) -> Dict[str, Any]:
251
+ return {
252
+ "tier": TIER_BY_STATUS[status],
253
+ "status": status,
254
+ "mode": mode,
255
+ "backends": backends_list,
256
+ "active_backend": active_backend,
257
+ "fix": fix,
258
+ "requires": requires,
259
+ "note": note,
260
+ "detail": detail,
261
+ "pin_var": pin_var,
262
+ "pin_flag": pin_flag,
263
+ "pinned": pinned,
264
+ }
265
+
266
+
267
+ # ---------------------------------------------------------------------------
268
+ # Chained sources (via U2 descriptors)
269
+ # ---------------------------------------------------------------------------
270
+
271
+ def _finding_json(finding: backends.BackendFinding) -> Dict[str, Any]:
272
+ return {
273
+ "name": finding.name,
274
+ "status": finding.status,
275
+ "detail": finding.detail,
276
+ "requires": finding.requires,
277
+ "fix": finding.prescription,
278
+ }
279
+
280
+
281
+ def _host_native_web_note(config: Dict[str, Any]) -> str:
282
+ """Doctor-local note when the host's own web search serves this run.
283
+
284
+ Keys on LAST30DAYS_NATIVE_SEARCH (via env.is_native_search) AND on
285
+ CLAUDECODE as a host signal - Claude Code always exposes a web-search tool,
286
+ but `doctor` run in a plain shell never sees the LAST30DAYS_NATIVE_SEARCH
287
+ the engine exports only for its own run, so without the CLAUDECODE signal it
288
+ would mislabel a fine setup as "degraded/keyless". Messaging only: it does
289
+ not change env.is_native_search or the engine's keyless-floor behavior. The
290
+ note names the signal actually detected so it never cites an env var the
291
+ user did not set.
292
+ """
293
+ if env.is_native_search(config):
294
+ return (
295
+ "host-native search active (LAST30DAYS_NATIVE_SEARCH): the host's "
296
+ "own web search serves this run; set a web key only if you want "
297
+ "engine-side web search"
298
+ )
299
+ if config.get("CLAUDECODE") or os.environ.get("CLAUDECODE"):
300
+ return (
301
+ "host-native web search active (Claude Code): the host's own web "
302
+ "search serves this run; set a web key only if you want "
303
+ "engine-side web search"
304
+ )
305
+ return ""
306
+
307
+
308
+ def _chained_record(source: str, config: Dict[str, Any]) -> Dict[str, Any]:
309
+ descriptor = backends.get_descriptor(source)
310
+ res = backends.resolve(source, config)
311
+ findings_json = [_finding_json(f) for f in res.findings]
312
+ common = dict(
313
+ mode=res.mode,
314
+ backends_list=findings_json,
315
+ active_backend=res.active_backend,
316
+ pin_var=descriptor.pin_var,
317
+ pin_flag=descriptor.pin_flag,
318
+ pinned=res.pinned,
319
+ )
320
+
321
+ if res.mode == backends.MODE_CONDITIONAL:
322
+ # Reddit: honest conditional wording (U2, verbatim), never a winner.
323
+ return _record(status=health.OK, note=res.conditional,
324
+ requires=res.findings[0].requires if res.findings else "",
325
+ **common)
326
+
327
+ by_name = {f.name: f for f in res.findings}
328
+ if res.tier == backends.TIER_OK:
329
+ active = by_name.get(res.active_backend)
330
+ return _record(status=health.OK, note=res.summary,
331
+ requires=active.requires if active else "", **common)
332
+
333
+ # Doctor-local (KTD-3): on a host that brings its own web search, the
334
+ # engine's web lanes (keyless floor or nothing configured) are intentionally
335
+ # dormant - report that, not an alarming "degraded/keyless". This must run
336
+ # before the WARN branch, because the keyless floor resolves to WARN and
337
+ # would otherwise return first. Messaging only; it never touches
338
+ # env.is_native_search or the engine's keyless-floor runtime behavior.
339
+ if source == "web":
340
+ host_note = _host_native_web_note(config)
341
+ if host_note:
342
+ return _record(
343
+ status="unconfigured",
344
+ note=host_note,
345
+ requires=res.findings[0].requires if res.findings else "",
346
+ **common,
347
+ )
348
+
349
+ if res.tier == backends.TIER_WARN:
350
+ active = by_name.get(res.active_backend)
351
+ return _record(status=health.DEGRADED, note=res.summary,
352
+ detail=active.detail if active else "",
353
+ fix=res.prescription,
354
+ requires=active.requires if active else "", **common)
355
+
356
+ # res.tier == error: separate "nothing configured" (tier off) from
357
+ # "configured but broken" (tier error).
358
+ if res.findings and all(f.status == health.MISSING for f in res.findings):
359
+ return _record(
360
+ status="unconfigured",
361
+ fix=res.prescription,
362
+ requires=res.findings[0].requires if res.findings else "",
363
+ note=f"no backend configured (chain: {' -> '.join(res.chain)})",
364
+ **common,
365
+ )
366
+ # Something IS configured/installed but won't serve: name the most
367
+ # specific failure in chain order.
368
+ status = health.ERROR
369
+ fix = res.prescription
370
+ detail = ""
371
+ failed: Optional[backends.BackendFinding] = None
372
+ for wanted in _SPECIFIC_FAILURES:
373
+ failed = next((f for f in res.findings if f.status == wanted), None)
374
+ if failed is not None:
375
+ status = wanted
376
+ fix = failed.prescription or res.prescription
377
+ detail = failed.detail
378
+ break
379
+ # Mirror the OK/WARN branches: the requirement named is the FAILED
380
+ # backend's, not chain[0]'s (which may be a different, merely-missing
381
+ # backend when the failure came from later in the chain).
382
+ return _record(status=status, fix=fix, detail=detail,
383
+ requires=failed.requires if failed
384
+ else (res.findings[0].requires if res.findings else ""),
385
+ **common)
386
+
387
+
388
+ # ---------------------------------------------------------------------------
389
+ # Single-backend sources
390
+ # ---------------------------------------------------------------------------
391
+
392
+ def _sc_fix() -> str:
393
+ return _fix_text(prescriptions.get("scrapecreators", "key_missing"))
394
+
395
+
396
+ def _sc_gated_record(config: Dict[str, Any], purpose: str) -> Dict[str, Any]:
397
+ if config.get("SCRAPECREATORS_API_KEY"):
398
+ return _record(status=health.OK, requires="SCRAPECREATORS_API_KEY",
399
+ detail=f"SCRAPECREATORS_API_KEY present ({purpose})")
400
+ return _record(status="unconfigured", requires="SCRAPECREATORS_API_KEY",
401
+ fix=_sc_fix())
402
+
403
+
404
+ def _sc_optin_record(config: Dict[str, Any], source: str, purpose: str) -> Dict[str, Any]:
405
+ """SC-gated source that ALSO requires an INCLUDE_SOURCES opt-in to run.
406
+
407
+ Unlike ``_sc_gated_record`` (used by the on-by-default TikTok/Instagram),
408
+ a key alone is not enough here: the pipeline only fires this source when it
409
+ is in INCLUDE_SOURCES. Reporting a bare key as Ready is the Threads
410
+ false-Ready bug - this mirrors ``_linkedin_record``'s correct gating so
411
+ doctor and the pipeline cannot disagree.
412
+ """
413
+ requires = f"SCRAPECREATORS_API_KEY + INCLUDE_SOURCES={source}"
414
+ if not config.get("SCRAPECREATORS_API_KEY"):
415
+ return _record(status="unconfigured", requires=requires, fix=_sc_fix())
416
+ if source in env.include_sources(config):
417
+ return _record(status=health.OK, requires=requires,
418
+ detail=f"SCRAPECREATORS_API_KEY present ({purpose})")
419
+ return _record(
420
+ status="opt-in", requires=requires,
421
+ fix=f"add {source} to INCLUDE_SOURCES (or request it via --search {source})",
422
+ note="key present; opt-in, never auto-activates",
423
+ )
424
+
425
+
426
+ def _reddit_record(config):
427
+ return _chained_record("reddit", config)
428
+
429
+
430
+ # Official-path wording. The bearer path is never described as
431
+ # parity with the connector lane.
432
+ X_BEARER_CAVEAT = x_api.BEARER_COVERAGE_NOTE
433
+ X_CONNECTOR_NOTE = "will use: built-in X tools or X connector (host-fetched at run time)"
434
+ X_CONNECTOR_ARMED = "host X lane armed (built-in X tools or X connector)"
435
+
436
+
437
+ def _x_will_use_note(record: Dict[str, Any], policy: env.XPolicy) -> str:
438
+ """The will-use line for a predicted X backend, shaped by the policy.
439
+
440
+ On an official-only host a pinned backend renders as ``will use: <name>
441
+ (pinned)`` and nothing else names it (the pin variable is not
442
+ advertised). Off it the backends summary is kept verbatim.
443
+ The bearer prediction carries the about-a-week caveat on every host.
444
+ """
445
+ name = record["active_backend"]
446
+ if policy.official_only:
447
+ qualifiers: List[str] = []
448
+ if record.get("pinned"):
449
+ qualifiers.append("pinned")
450
+ if name == "xapi":
451
+ qualifiers.append(X_BEARER_CAVEAT)
452
+ return f"will use: {name}" + (f" ({'; '.join(qualifiers)})" if qualifiers else "")
453
+ note = record.get("note") or f"will use: {name}"
454
+ if name == "xapi":
455
+ if note.endswith(")"):
456
+ return f"{note[:-1]}; {X_BEARER_CAVEAT})"
457
+ return f"{note} ({X_BEARER_CAVEAT})"
458
+ return note
459
+
460
+
461
+ def _x_record(config):
462
+ record = _chained_record("x", config)
463
+ policy = env.x_policy(config)
464
+ if policy.official_only and not record.get("pinned"):
465
+ # The pin is documented in CONFIGURATION.md only; doctor never
466
+ # advertises the knob on an official-only host.
467
+ record["pin_var"] = None
468
+ if record.get("active_backend"):
469
+ record["note"] = _x_will_use_note(record, policy)
470
+ return record
471
+ # The declared X connector lane serves X when no engine backend is
472
+ # predicted (the same precedence as diagnose.x_backend). Host-independent:
473
+ # the envelope is accepted anywhere.
474
+ if env.x_host_lane_declared(config):
475
+ record["status"] = health.OK
476
+ record["tier"] = TIER_BY_STATUS[health.OK]
477
+ record["active_backend"] = "connector"
478
+ record["note"] = X_CONNECTOR_NOTE
479
+ record["detail"] = ""
480
+ record["fix"] = ""
481
+ return record
482
+ # Diagnose/doctor load config in plan_only mode, so browser cookies are not
483
+ # extracted and every X backend reads as statically missing -> unconfigured.
484
+ # But if bird is installed and FROM_BROWSER will authenticate X at run time,
485
+ # a normal run serves X fine (this is how the reporting user pulled 29 posts
486
+ # while doctor said "Off"). Reuse the existing shared predicate so doctor and
487
+ # diagnose cannot drift. It reads no cookie *values*, so it confirms a run
488
+ # will *attempt* browser auth, not that the session is currently valid -
489
+ # keep the note honest and point at the verified key-backed path.
490
+ #
491
+ # Policy-gated: on an official-only host no run-time cookie source
492
+ # exists unless bird is pinned, and then the note names only the pin.
493
+ #
494
+ # This check MUST come before grok normalization: a pending bird path or
495
+ # a recorded setup denial takes precedence over an unused grok store.
496
+ # Handle both "unconfigured" (all backends missing) and "error" (grok present
497
+ # but opt-in, no auto-chain backend usable) when pending bird applies.
498
+ #
499
+ # HOWEVER: pending bird must NOT replace a record that has a configured
500
+ # auto-chain backend in ERROR/DEGRADED/BROKEN/TIMEOUT. Same rule as the
501
+ # grok normalizer: only upgrade when no auto backend is configured-but-broken.
502
+ auto_chain_names = set(env.x_auto_chain(config))
503
+ pending_bird = policy.cookie_discovery and env.x_pending_browser_auth(
504
+ config, local_only=True
505
+ )
506
+ reported_denials = set((config.get("LAST30DAYS_X_COOKIE_ACCESS_DENIED") or "").split(","))
507
+ known_browsers = set(env.COOKIE_BROWSER_NAMES)
508
+ if (
509
+ policy.cookie_discovery
510
+ and str(config.get("BROWSER_CONSENT") or "").lower() in {"1", "true", "yes", "on"}
511
+ and str(config.get("FROM_BROWSER") or "").strip().lower() != "off"
512
+ ):
513
+ denied_browsers = sorted(reported_denials & known_browsers)
514
+ else:
515
+ denied_browsers = []
516
+ if denied_browsers and record.get("pinned") and env.x_backend_pin(config) != "bird":
517
+ observation = (
518
+ "Last setup: permission denied reading X cookies from "
519
+ f"{', '.join(denied_browsers)}; current access not checked"
520
+ )
521
+ record["note"] = f"{record['note']}; {observation}" if record["note"] else observation
522
+ return record
523
+ if (pending_bird or denied_browsers) and record["status"] in ("unconfigured", health.ERROR):
524
+ backends_list = record.get("backends", [])
525
+ auto_backends = [b for b in backends_list if b.get("name") in auto_chain_names]
526
+ # Only apply pending-bird upgrade if ALL auto-chain backends are MISSING.
527
+ # If any auto backend is configured but broken, keep that error.
528
+ all_auto_missing = all(
529
+ b.get("status") == health.MISSING for b in auto_backends
530
+ )
531
+ if all_auto_missing:
532
+ if denied_browsers:
533
+ record["status"] = health.ERROR
534
+ record["tier"] = TIER_BY_STATUS[health.ERROR]
535
+ record["note"] = (
536
+ "Last setup: permission denied reading X cookies from "
537
+ f"{', '.join(denied_browsers)}; current access not checked"
538
+ )
539
+ record["fix"] = env.X_COOKIE_ACCESS_FIX
540
+ return record
541
+ record["status"] = health.OK
542
+ record["tier"] = TIER_BY_STATUS[health.OK]
543
+ if policy.official_only:
544
+ record["note"] = "will use: bird (pinned)"
545
+ else:
546
+ record["note"] = (
547
+ "will use: bird (browser cookies; session not verified until a run "
548
+ "- add XAI_API_KEY for a verified, cookie-free path)"
549
+ )
550
+ record["fix"] = ""
551
+ return record
552
+ #
553
+ # Grok is opt-in only: a leftover ~/.grok/auth.json must never steal the X
554
+ # lane. The grok backend appears in the chain findings (for visibility) but
555
+ # is never auto-selected. Doctor reports it as "available, unused - pin
556
+ # LAST30DAYS_X_BACKEND=grok to enable" rather than "will use: grok".
557
+ #
558
+ # Policy-gated: on an official-only host the CLI is not probed
559
+ # unless pinned and is never offered, so this branch does not run there
560
+ # (no "pin LAST30DAYS_X_BACKEND=grok" note on a Grok Bot host).
561
+ #
562
+ # R3/R8: When no auto-chain backend is CONFIGURED (all MISSING) but grok has
563
+ # any non-MISSING status, X is unconfigured/skipped - NOT broken/auth-failed.
564
+ # The tier must be "off" (unconfigured), not "error" (NOT WORKING).
565
+ #
566
+ # HOWEVER: if an auto-chain backend IS configured but broken (ERROR/DEGRADED),
567
+ # do NOT normalize to unconfigured. Keep that backend's error and repair
568
+ # guidance. Unused grok must not swallow a genuine auto-chain failure.
569
+ #
570
+ # Do NOT apply this normalization when pending browser auth would make bird
571
+ # usable — check pending_bird first (handled above via early return).
572
+ if (
573
+ not policy.official_only
574
+ and record["tier"] == TIER_ERROR
575
+ and not record.get("pinned")
576
+ and record.get("active_backend") is None
577
+ and not pending_bird
578
+ ):
579
+ backends_list = record.get("backends", [])
580
+ auto_backends = [b for b in backends_list if b.get("name") in auto_chain_names]
581
+ # Only normalize if ALL auto-chain backends are MISSING (not configured).
582
+ # If any auto backend is ERROR/DEGRADED/BROKEN/TIMEOUT, keep that error.
583
+ all_auto_missing = all(
584
+ b.get("status") == health.MISSING for b in auto_backends
585
+ )
586
+ if not all_auto_missing:
587
+ # An auto-chain backend is configured but broken — do NOT normalize.
588
+ # Keep the original error and its repair guidance.
589
+ return record
590
+ grok_finding = next(
591
+ (b for b in backends_list if b.get("name") == "grok"),
592
+ None,
593
+ )
594
+ if grok_finding and grok_finding.get("status") in (
595
+ health.OK,
596
+ health.DEGRADED,
597
+ health.ERROR,
598
+ ):
599
+ record["status"] = "unconfigured"
600
+ record["tier"] = TIER_OFF
601
+ if grok_finding.get("status") == health.ERROR:
602
+ record["note"] = (
603
+ "X unconfigured; grok CLI store is broken but unused (opt-in only) — "
604
+ "pin LAST30DAYS_X_BACKEND=grok to enable, then fix the store"
605
+ )
606
+ else:
607
+ record["note"] = (
608
+ "X unconfigured; grok CLI available but opt-in only — "
609
+ "pin LAST30DAYS_X_BACKEND=grok to enable"
610
+ )
611
+ record["fix"] = ""
612
+ return record
613
+ xapi_finding = next(
614
+ (b for b in backends_list if b.get("name") == "xapi"),
615
+ None,
616
+ )
617
+ if xapi_finding and xapi_finding.get("status") in (health.OK, health.DEGRADED):
618
+ # Same shape as the grok note: a configured opt-in backend is not
619
+ # a broken X, it is an unconfigured X with a one-line enable.
620
+ record["status"] = "unconfigured"
621
+ record["tier"] = TIER_OFF
622
+ record["note"] = (
623
+ "X unconfigured; X_BEARER_TOKEN is set but the X API backend is opt-in "
624
+ "on this host — pin LAST30DAYS_X_BACKEND=xapi to enable"
625
+ )
626
+ record["fix"] = ""
627
+ return record
628
+ return record
629
+
630
+
631
+ def _youtube_record(config):
632
+ record = _chained_record("youtube", config)
633
+ if record["status"] != health.OK:
634
+ return record
635
+ notes: List[str] = []
636
+ # yt-dlp already provides search + transcripts. A transcription key only
637
+ # backfills captions for the occasional caption-free video - an enhancement,
638
+ # not a sign YouTube is broken.
639
+ if not env.transcription_providers(config):
640
+ entry = prescriptions.get("youtube", "transcription_key_missing")
641
+ notes.append(
642
+ "search + transcripts work; a transcription key only adds "
643
+ "captions for caption-free videos"
644
+ )
645
+ record["fix"] = _fix_text(entry)
646
+ # Comment *text* is free via yt-dlp, so this caveat only fires when yt-dlp
647
+ # is absent and the legacy ScrapeCreators path is the only one left. Never
648
+ # prescribe a paid key for something the installed toolchain already does.
649
+ if not env.is_youtube_comments_available(config):
650
+ notes.append(
651
+ "comment text needs yt-dlp (free) or a ScrapeCreators key "
652
+ "+ youtube_comments opt-in"
653
+ )
654
+ # Actionable fix, matching the transcription branch. The transcription
655
+ # fix takes precedence when both caveats fire (one fix line per record).
656
+ if not record["fix"]:
657
+ if not config.get("SCRAPECREATORS_API_KEY"):
658
+ record["fix"] = _sc_fix()
659
+ else:
660
+ record["fix"] = (
661
+ "add youtube_comments to INCLUDE_SOURCES in "
662
+ "~/.config/last30days/.env to enable YouTube comment text"
663
+ )
664
+ if notes:
665
+ joined = "; ".join(notes)
666
+ record["note"] = (record["note"] + "; " + joined) if record["note"] else joined
667
+ return record
668
+
669
+
670
+ def _web_record(config):
671
+ return _chained_record("web", config)
672
+
673
+
674
+ def _hackernews_record(config):
675
+ return _record(status=health.OK, requires="none (free Algolia API)")
676
+
677
+
678
+ def _devto_record(config):
679
+ requires = "none (public Forem API)"
680
+ if "devto" in env.include_sources(config):
681
+ return _record(status=health.OK, requires=requires)
682
+ return _record(
683
+ status="opt-in", requires=requires,
684
+ fix="add devto to INCLUDE_SOURCES (or request it via --search devto)",
685
+ note="off by default; --devto-tags activates it for a run",
686
+ )
687
+
688
+
689
+ def _polymarket_record(config):
690
+ return _record(status=health.OK, requires="none (public API)")
691
+
692
+
693
+ def _github_record(config):
694
+ authed = bool(config.get("GITHUB_TOKEN") or env.read_secret_env("GITHUB_TOKEN") or shutil.which("gh"))
695
+ detail = (
696
+ "authenticated tier (GITHUB_TOKEN or gh CLI)"
697
+ if authed
698
+ else "unauthenticated REST tier (lower rate limits; GITHUB_TOKEN or gh raises them)"
699
+ )
700
+ return _record(status=health.OK, detail=detail,
701
+ requires="none (GITHUB_TOKEN or gh CLI optional)")
702
+
703
+
704
+ def _digg_record(config):
705
+ probe = health.probe_dependency("digg-pp-cli")
706
+ requires = "digg-pp-cli on the agent-subprocess PATH"
707
+ if probe.ok:
708
+ return _record(status=health.OK, detail=probe.detail, requires=requires)
709
+ entry = prescriptions.for_dependency_probe(probe)
710
+ fix = _fix_text(entry) if entry else probe.prescription
711
+ if probe.status == health.MISSING and not probe.off_path:
712
+ # Never installed: an optional source that simply isn't enabled.
713
+ return _record(status="opt-in", fix=fix, detail=probe.detail, requires=requires)
714
+ # Installed but off-PATH, broken, or timing out: configured-but-broken.
715
+ return _record(status=probe.status, fix=fix, detail=probe.detail, requires=requires)
716
+
717
+
718
+ def _cli_gated_record(config, cli_name: str, purpose: str):
719
+ """A source gated purely on a keyless downloaded CLI (mirrors _digg_record).
720
+
721
+ ok -> installed and functional; opt-in -> never installed (an optional
722
+ source simply not enabled); its failing status -> installed off-PATH,
723
+ broken, or timing out (configured-but-broken).
724
+ """
725
+ probe = health.probe_dependency(cli_name)
726
+ requires = f"{cli_name} on the agent-subprocess PATH"
727
+ if probe.ok:
728
+ return _record(status=health.OK, detail=probe.detail, requires=requires)
729
+ entry = prescriptions.for_dependency_probe(probe)
730
+ fix = _fix_text(entry) if entry else probe.prescription
731
+ if probe.status == health.MISSING and not probe.off_path:
732
+ return _record(status="opt-in", fix=fix, detail=probe.detail, requires=requires)
733
+ return _record(status=probe.status, fix=fix, detail=probe.detail, requires=requires)
734
+
735
+
736
+ def _techmeme_record(config):
737
+ return _cli_gated_record(config, "techmeme-pp-cli", "techmeme")
738
+
739
+
740
+ def _arxiv_record(config):
741
+ return _cli_gated_record(config, "arxiv-pp-cli", "arxiv")
742
+
743
+
744
+ def _trustpilot_record(config):
745
+ return _cli_gated_record(config, "trustpilot-pp-cli", "trustpilot")
746
+
747
+
748
+ def _amazon_record(config):
749
+ """Amazon buyer signals: CLI-gated *and* auth-gated.
750
+
751
+ Unlike the other CLI-gated sources, a present binary is not enough --
752
+ the Bright Data CLI owns its own login, so a user can have `brightdata`
753
+ on PATH and still get nothing. Report those states separately: an
754
+ unauthenticated install is configured-but-broken (a real fix exists and
755
+ the user wants to hear it), while a missing binary is just an optional
756
+ source nobody opted into.
757
+ """
758
+ probe = health.probe_dependency(brightdata.CLI_BIN)
759
+ requires = f"{brightdata.CLI_BIN} on the agent-subprocess PATH, logged in"
760
+ if probe.ok:
761
+ if brightdata.has_credentials(config):
762
+ return _record(status=health.OK, detail=probe.detail, requires=requires)
763
+ return _record(
764
+ status="unconfigured",
765
+ fix="run `brightdata login` to activate the amazon source",
766
+ detail="brightdata is installed but has no credentials",
767
+ requires=requires,
768
+ )
769
+ entry = prescriptions.for_dependency_probe(probe)
770
+ fix = _fix_text(entry) if entry else probe.prescription
771
+ if probe.status == health.MISSING and not probe.off_path:
772
+ return _record(
773
+ status="opt-in",
774
+ fix="npm i -g @brightdata/cli && brightdata login",
775
+ detail=probe.detail,
776
+ requires=requires,
777
+ )
778
+ return _record(status=probe.status, fix=fix, detail=probe.detail, requires=requires)
779
+
780
+
781
+ def _tiktok_record(config):
782
+ return _sc_gated_record(config, "tiktok")
783
+
784
+
785
+ def _instagram_record(config):
786
+ return _sc_gated_record(config, "instagram")
787
+
788
+
789
+ def _threads_record(config):
790
+ # Threads needs the key AND an INCLUDE_SOURCES=threads opt-in to run, so it
791
+ # is opt-in-gated (not on-by-default like TikTok/Instagram).
792
+ return _sc_optin_record(config, "threads", "threads")
793
+
794
+
795
+ def _telegram_record(config):
796
+ # Telegram needs the key AND an INCLUDE_SOURCES=telegram opt-in AND a
797
+ # channel list (TELEGRAM_SOURCES). Without named channels there is no
798
+ # discovery endpoint to call.
799
+ requires = "SCRAPECREATORS_API_KEY + INCLUDE_SOURCES=telegram + TELEGRAM_SOURCES"
800
+ if not config.get("SCRAPECREATORS_API_KEY"):
801
+ return _record(status="unconfigured", requires=requires, fix=_sc_fix())
802
+ from . import telegram
803
+ channels = telegram._get_channel_sources(config)
804
+ if "telegram" in env.include_sources(config):
805
+ if channels:
806
+ return _record(
807
+ status=health.OK,
808
+ requires=requires,
809
+ detail=f"SCRAPECREATORS_API_KEY present, {len(channels)} channel(s) configured",
810
+ )
811
+ return _record(
812
+ status="unconfigured",
813
+ requires=requires,
814
+ fix="set TELEGRAM_SOURCES to a comma-separated list of public channel handles",
815
+ note="key present and opt-in active, but no channels configured",
816
+ )
817
+ return _record(
818
+ status="opt-in",
819
+ requires=requires,
820
+ fix="add telegram to INCLUDE_SOURCES and set TELEGRAM_SOURCES to channel handles",
821
+ note="key present; opt-in only, channels required",
822
+ )
823
+
824
+
825
+ def _bluesky_record(config):
826
+ if env.is_bluesky_available(config):
827
+ return _record(status=health.OK, requires="BSKY_HANDLE + BSKY_APP_PASSWORD")
828
+ return _record(
829
+ status="unconfigured",
830
+ requires="BSKY_HANDLE + BSKY_APP_PASSWORD",
831
+ fix=_fix_text(prescriptions.get("bluesky", "app_password_missing")),
832
+ )
833
+
834
+
835
+ def _truthsocial_record(config):
836
+ if env.is_truthsocial_available(config):
837
+ return _record(status=health.OK, requires="TRUTHSOCIAL_TOKEN")
838
+ return _record(
839
+ status="unconfigured",
840
+ requires="TRUTHSOCIAL_TOKEN",
841
+ fix=_fix_text(prescriptions.get("truthsocial", "token_missing")),
842
+ )
843
+
844
+
845
+ def _perplexity_record(config):
846
+ requires = (
847
+ "PERPLEXITY_API_KEY or OPENROUTER_API_KEY + "
848
+ "INCLUDE_SOURCES=perplexity"
849
+ )
850
+ has_direct_key = bool(config.get("PERPLEXITY_API_KEY"))
851
+ has_openrouter_key = bool(config.get("OPENROUTER_API_KEY"))
852
+ has_key = has_direct_key or has_openrouter_key
853
+ include = env.include_sources(config)
854
+ if not has_key:
855
+ return _record(
856
+ status="unconfigured", requires=requires,
857
+ fix=(
858
+ "set PERPLEXITY_API_KEY or OPENROUTER_API_KEY in "
859
+ "~/.config/last30days/.env, then add perplexity to INCLUDE_SOURCES"
860
+ ),
861
+ )
862
+ if "perplexity" in include:
863
+ return _record(
864
+ status=health.OK,
865
+ requires=requires,
866
+ note=(
867
+ "direct Agent/Search APIs"
868
+ if has_direct_key
869
+ else "OpenRouter Sonar compatibility fallback"
870
+ ),
871
+ )
872
+ return _record(
873
+ status="opt-in", requires=requires,
874
+ fix="add perplexity to INCLUDE_SOURCES (or request it via --search perplexity)",
875
+ note="key present; source runs only when opted in",
876
+ )
877
+
878
+
879
+ def _meta_ads_record(config):
880
+ return _sc_optin_record(config, "meta_ads", "Meta Ad Library")
881
+
882
+
883
+ def _linkedin_record(config):
884
+ requires = "SCRAPECREATORS_API_KEY + INCLUDE_SOURCES=linkedin"
885
+ if not config.get("SCRAPECREATORS_API_KEY"):
886
+ return _record(status="unconfigured", requires=requires, fix=_sc_fix())
887
+ if "linkedin" in env.include_sources(config):
888
+ return _record(status=health.OK, requires=requires)
889
+ return _record(
890
+ status="opt-in", requires=requires,
891
+ fix="add linkedin to INCLUDE_SOURCES (or request it via --search linkedin)",
892
+ note="key present; power-user opt-in, never auto-activates",
893
+ )
894
+
895
+
896
+ def _pinterest_record(config):
897
+ requires = "SCRAPECREATORS_API_KEY; requested-only (--search pinterest)"
898
+ if not config.get("SCRAPECREATORS_API_KEY"):
899
+ return _record(status="unconfigured", requires=requires, fix=_sc_fix())
900
+ return _record(
901
+ status="opt-in", requires=requires,
902
+ fix="request it explicitly via --search pinterest (or INCLUDE_SOURCES)",
903
+ note="key present; runs only when requested",
904
+ )
905
+
906
+
907
+ def _xiaohongshu_record(config):
908
+ requires = (
909
+ "logged-in Xiaohongshu browser-session service; requested-only "
910
+ "(--search xhs)"
911
+ )
912
+ entry = prescriptions.get("xiaohongshu", "service_unreachable")
913
+ if config.get("XIAOHONGSHU_API_BASE"):
914
+ return _record(
915
+ status=health.OK, requires=requires,
916
+ note=(
917
+ "XIAOHONGSHU_API_BASE configured; service reachability is not "
918
+ "probed (doctor makes no network calls)"
919
+ ),
920
+ )
921
+ return _record(
922
+ status="opt-in",
923
+ requires=requires,
924
+ fix=_fix_text(entry),
925
+ note=(
926
+ "auto-probes http://localhost:18060 first, then "
927
+ "http://host.docker.internal:18060"
928
+ ),
929
+ )
930
+
931
+
932
+ def _jobs_record(config):
933
+ return _record(
934
+ status="opt-in",
935
+ requires="none; activates for company topics or --hiring-signals",
936
+ note="on-demand source: no configuration needed",
937
+ )
938
+
939
+
940
+ def _count_saved_briefs(memory_dir) -> int:
941
+ """Cheap count of saved research briefs (directory listing, no file parse).
942
+
943
+ Globs the ``*-raw*.md`` artifacts the engine writes, deliberately avoiding
944
+ library.scan_library's read_text+parse of every file - a count does not
945
+ need the parsed content, and the full scan adds real latency to every
946
+ `doctor` run on a large library.
947
+ """
948
+ path = Path(memory_dir).expanduser()
949
+ return sum(1 for _ in path.glob("*-raw*.md"))
950
+
951
+
952
+ def _library_record(config):
953
+ """Local research library that feeds the report's 'From your library' block.
954
+
955
+ This is not a network source - it reports how many saved briefs are indexed
956
+ so the 'From your library' block's presence is explained on the health
957
+ surface. Read-only and never fails the run: an empty store, a missing store,
958
+ or a SQLite build without FTS5 all resolve to an informational OK line.
959
+ """
960
+ from . import library, library_index
961
+
962
+ if not library_index.fts5_available():
963
+ return _record(
964
+ status=health.OK,
965
+ requires="none (local SQLite)",
966
+ note=(
967
+ "search index unavailable (this SQLite build lacks FTS5); "
968
+ "saved briefs still render, `library search` is disabled"
969
+ ),
970
+ )
971
+ try:
972
+ count = _count_saved_briefs(
973
+ config.get("LAST30DAYS_MEMORY_DIR") or library.DEFAULT_MEMORY_DIR
974
+ )
975
+ except Exception:
976
+ return _record(
977
+ status=health.OK,
978
+ requires="none (local SQLite)",
979
+ note="local research library (powers the 'From your library' block)",
980
+ )
981
+ if count == 0:
982
+ note = "no saved briefs yet - runs you save build this over time"
983
+ else:
984
+ plural = "brief" if count == 1 else "briefs"
985
+ note = (
986
+ f"{count} saved {plural}; powers the 'From your library' block "
987
+ "(LAST30DAYS_LIBRARY_CONTEXT=off to hide)"
988
+ )
989
+ return _record(status=health.OK, requires="none (local SQLite)", note=note)
990
+
991
+
992
+ _SOURCE_BUILDERS: Dict[str, Callable[[Dict[str, Any]], Dict[str, Any]]] = {
993
+ "reddit": _reddit_record,
994
+ "x": _x_record,
995
+ "youtube": _youtube_record,
996
+ "web": _web_record,
997
+ "hackernews": _hackernews_record,
998
+ "polymarket": _polymarket_record,
999
+ "github": _github_record,
1000
+ "devto": _devto_record,
1001
+ "digg": _digg_record,
1002
+ "techmeme": _techmeme_record,
1003
+ "arxiv": _arxiv_record,
1004
+ "trustpilot": _trustpilot_record,
1005
+ "amazon": _amazon_record,
1006
+ "meta_ads": _meta_ads_record,
1007
+ "tiktok": _tiktok_record,
1008
+ "instagram": _instagram_record,
1009
+ "threads": _threads_record,
1010
+ "telegram": _telegram_record,
1011
+ "bluesky": _bluesky_record,
1012
+ "truthsocial": _truthsocial_record,
1013
+ "perplexity": _perplexity_record,
1014
+ "linkedin": _linkedin_record,
1015
+ "pinterest": _pinterest_record,
1016
+ "xiaohongshu": _xiaohongshu_record,
1017
+ "jobs": _jobs_record,
1018
+ "library": _library_record,
1019
+ }
1020
+
1021
+
1022
+ # ---------------------------------------------------------------------------
1023
+ # Run-evidence overlay (U1): read the engine's last-report.json
1024
+ #
1025
+ # doctor predicts config health; a research run records what ACTUALLY happened
1026
+ # per source in Report.source_status. Reading the last run lets doctor tell
1027
+ # "configured" from "working" (the four-state audit) and powers --postmortem.
1028
+ # This is a read-only reuse of the engine's existing report cache - no new
1029
+ # writer. The schema stamp + filename mirror REPORT_CACHE_VERSION /
1030
+ # _last_report_cache_path() in last30days.py (the same mirror pattern the
1031
+ # doctor-cache block below already uses for its own schema stamp).
1032
+ # ---------------------------------------------------------------------------
1033
+
1034
+ REPORT_CACHE_SCHEMA_VERSION = "last30days-report-cache/v1"
1035
+ REPORT_CACHE_FILENAME = "last-report.json"
1036
+ DEFAULT_REPORT_CACHE_TTL_SECONDS = 3600
1037
+
1038
+
1039
+ def _last_report_path() -> Optional[Path]:
1040
+ """The engine's last-report.json, beside the doctor cache (None in clean mode)."""
1041
+ if env.CONFIG_DIR is None:
1042
+ return None
1043
+ return env.CONFIG_DIR / REPORT_CACHE_FILENAME
1044
+
1045
+
1046
+ def load_run_evidence(
1047
+ config: Dict[str, Any], ttl_seconds: int = DEFAULT_REPORT_CACHE_TTL_SECONDS
1048
+ ) -> Dict[str, Any]:
1049
+ """Return the last research run's per-source outcomes, read-only.
1050
+
1051
+ Shape: ``{"outcomes": {source: {state, items_returned, detail, fix_hint,
1052
+ at}}, "topic": str|None, "at": str|None, "fresh": bool, "present": bool}``.
1053
+
1054
+ Any failure mode - absent file, unreadable, invalid JSON, schema mismatch,
1055
+ wrong shape - yields the empty, not-present result and never raises
1056
+ (doctor's exit-0 contract is absolute). ``fresh`` reflects the report TTL:
1057
+ ``--postmortem`` reads regardless of freshness (labeling the age), while the
1058
+ plain-``doctor`` overlay consumes only fresh evidence so a week-old run
1059
+ cannot mislabel a source as WORKING today.
1060
+ """
1061
+ empty = {"outcomes": {}, "topic": None, "at": None, "fresh": False, "present": False}
1062
+ path = _last_report_path()
1063
+ if path is None or not path.exists():
1064
+ return empty
1065
+ try:
1066
+ payload = json.loads(path.read_text(encoding="utf-8"))
1067
+ except Exception:
1068
+ return empty
1069
+ if not isinstance(payload, dict):
1070
+ return empty
1071
+ if payload.get("schema") != REPORT_CACHE_SCHEMA_VERSION:
1072
+ return empty
1073
+ reports = payload.get("reports") or []
1074
+ if not reports or not isinstance(reports[0], dict):
1075
+ return empty
1076
+ report = reports[0].get("report")
1077
+ if not isinstance(report, dict):
1078
+ return empty
1079
+ raw_status = report.get("source_status") or {}
1080
+ outcomes: Dict[str, Any] = {}
1081
+ if isinstance(raw_status, dict):
1082
+ for source, outcome in raw_status.items():
1083
+ if not isinstance(outcome, dict):
1084
+ continue
1085
+ state = outcome.get("state")
1086
+ if not isinstance(state, str):
1087
+ continue
1088
+ outcomes[source] = {
1089
+ "state": state,
1090
+ "items_returned": int(outcome.get("items_returned") or 0),
1091
+ "detail": outcome.get("detail"),
1092
+ "fix_hint": outcome.get("fix_hint"),
1093
+ "at": outcome.get("at"),
1094
+ }
1095
+ timestamp = payload.get("timestamp")
1096
+ return {
1097
+ "outcomes": outcomes,
1098
+ "topic": payload.get("topic"),
1099
+ "at": timestamp or report.get("generated_at"),
1100
+ "fresh": bool(env.is_timestamp_fresh(timestamp, ttl_seconds)),
1101
+ "present": True,
1102
+ }
1103
+
1104
+
1105
+ # ---------------------------------------------------------------------------
1106
+ # Backup + comment sub-lanes (U7 / R8, R9)
1107
+ #
1108
+ # Backups (Reddit's SC backfill, YouTube's SC transcript/search backstop, X's
1109
+ # cookie-vs-key dual path) and comment lanes (youtube/tiktok/instagram) are not
1110
+ # independent sources - they are capabilities of their parent. doctor surfaces
1111
+ # them as indented sub-lines so "is a backup armed when yt-dlp is rate-limited?"
1112
+ # is answerable at a glance without inventing fake sources.
1113
+ # ---------------------------------------------------------------------------
1114
+
1115
+ def _x_auth_path(config: Dict[str, Any]) -> Dict[str, Any]:
1116
+ """The "X auth path" sub-lane, routed through the X policy.
1117
+
1118
+ Off an official-only host the wording is unchanged (key-backed, cookie
1119
+ path, or nothing armed). On one it names only the official path: the
1120
+ connector lane when declared, else the bearer (with the about-a-week
1121
+ caveat), the xAI key, or xurl; a pinned non-official backend is named
1122
+ once, on the will-use line, never here.
1123
+ """
1124
+ policy = env.x_policy(config)
1125
+ if not policy.official_only:
1126
+ has_key = bool(config.get("XAI_API_KEY") or config.get("XQUIK_API_KEY"))
1127
+ cookie = bool(env.x_pending_browser_auth(config, local_only=True))
1128
+ if has_key:
1129
+ note = "XAI_API_KEY key-backed path (verified, cookie-free)"
1130
+ elif cookie:
1131
+ note = (
1132
+ "browser-cookie path primary; add XAI_API_KEY for a verified "
1133
+ "cookie-free backup"
1134
+ )
1135
+ else:
1136
+ note = "no auth path armed"
1137
+ return {"name": "X auth path", "armed": has_key or cookie, "note": note}
1138
+ if env.x_host_lane_declared(config):
1139
+ return {"name": "X auth path", "armed": True, "note": X_CONNECTOR_ARMED}
1140
+ source = env.get_x_source(config, local_only=True)
1141
+ if source == "xapi":
1142
+ note = f"X_BEARER_TOKEN path ({X_BEARER_CAVEAT})"
1143
+ elif source == "xai":
1144
+ note = "XAI_API_KEY licensed path (console.x.ai)"
1145
+ elif source == "xurl":
1146
+ note = "X API through the xurl CLI (stored OAuth2 login)"
1147
+ elif source:
1148
+ note = "explicit backend pin"
1149
+ else:
1150
+ note = (
1151
+ "no official X path armed (use Grok Bot's built-in X tools or add the X for Grok Bot plugin, or set "
1152
+ "X_BEARER_TOKEN or XAI_API_KEY)"
1153
+ )
1154
+ return {"name": "X auth path", "armed": bool(source), "note": note}
1155
+
1156
+
1157
+ def _sub_lanes_for(source: str, config: Dict[str, Any]):
1158
+ """Return (backups, comments) metadata for a source, or ([], None)."""
1159
+ backups: List[Dict[str, Any]] = []
1160
+ comments: Optional[Dict[str, Any]] = None
1161
+ has_sc = bool(config.get("SCRAPECREATORS_API_KEY"))
1162
+ if source == "reddit":
1163
+ floor = env.reddit_sc_min_items(config)
1164
+ if floor > 0:
1165
+ note = f"fills in when results fall below the {floor}-item floor"
1166
+ else:
1167
+ note = "fills in when the free public path returns nothing"
1168
+ backups.append({"name": "ScrapeCreators backfill", "armed": has_sc, "note": note})
1169
+ elif source == "youtube":
1170
+ backups.append({
1171
+ "name": "ScrapeCreators transcript/search backstop", "armed": has_sc,
1172
+ "note": "used when yt-dlp is rate-limited or bot-gated",
1173
+ })
1174
+ comments = {"enabled": bool(env.is_youtube_comments_available(config))}
1175
+ elif source == "x":
1176
+ backups.append(_x_auth_path(config))
1177
+ elif source == "tiktok":
1178
+ comments = {"enabled": bool(env.is_tiktok_comments_available(config))}
1179
+ elif source == "instagram":
1180
+ comments = {"enabled": bool(env.is_instagram_comments_available(config))}
1181
+ return backups, comments
1182
+
1183
+
1184
+ # ---------------------------------------------------------------------------
1185
+ # Aggregation
1186
+ # ---------------------------------------------------------------------------
1187
+
1188
+ def _engine_version() -> str:
1189
+ try:
1190
+ from . import render
1191
+
1192
+ version = render._skill_version()
1193
+ except Exception:
1194
+ version = None
1195
+ # render's parent-walking helper falls back to "?"; doctor says "unknown".
1196
+ if not version or version == "?":
1197
+ return "unknown"
1198
+ return version
1199
+
1200
+
1201
+ def _setup_block(config: Dict[str, Any]) -> Dict[str, Any]:
1202
+ keys_present = {var: bool(config.get(var)) for var in KEY_PRESENCE_VARS}
1203
+ keys_present["x_browser_cookies"] = bool(
1204
+ config.get("AUTH_TOKEN") and config.get("CT0")
1205
+ )
1206
+ keys_present["bluesky_app_password"] = bool(
1207
+ config.get("BSKY_HANDLE") and config.get("BSKY_APP_PASSWORD")
1208
+ )
1209
+ return {
1210
+ "setup_complete": env.is_setup_complete(config),
1211
+ "keys_present": keys_present,
1212
+ # get_config() rejected these before any presence check ran, so
1213
+ # keys_present already reads them as absent. Carrying the names here is
1214
+ # what lets the text renderer say *why* they are absent instead of
1215
+ # leaving the user to read "credentials present: none". Only keys left
1216
+ # unset are listed: one whose placeholder fell through to a real
1217
+ # lower-priority credential is configured and does not appear.
1218
+ "unsubstituted_templates": env.templated_config_keys(config),
1219
+ }
1220
+
1221
+
1222
+ def _permissions_block(config: Dict[str, Any]) -> Dict[str, Any]:
1223
+ """Secret-free permission summary via the existing preflight provider."""
1224
+ from . import pipeline
1225
+
1226
+ diag = pipeline.diagnose(config, None, safe=True)
1227
+ return diag["permission_preflight"]
1228
+
1229
+
1230
+ def build_report(config: Dict[str, Any]) -> Dict[str, Any]:
1231
+ """Aggregate every health provider into one report dict.
1232
+
1233
+ Per-source exceptions are isolated: a failing builder yields an
1234
+ ``error`` record for that source and the rest of the report survives.
1235
+ """
1236
+ def _build_one(name: str) -> Dict[str, Any]:
1237
+ try:
1238
+ return _SOURCE_BUILDERS[name](config)
1239
+ except Exception as exc: # one bad probe must not blank the report
1240
+ return _record(
1241
+ status=health.ERROR,
1242
+ detail=f"probe failed: {type(exc).__name__}: {exc}",
1243
+ fix=_fix_text(prescriptions.get(name, "probe_error")),
1244
+ )
1245
+
1246
+ # Builders are independent probes (subprocess/filesystem bound), so run
1247
+ # them concurrently. ``pool.map`` preserves SOURCE_ORDER, keeping the
1248
+ # sources dict insertion order — and render grouping — deterministic.
1249
+ with concurrent.futures.ThreadPoolExecutor(
1250
+ max_workers=min(8, len(SOURCE_ORDER))
1251
+ ) as pool:
1252
+ sources: Dict[str, Dict[str, Any]] = dict(
1253
+ zip(SOURCE_ORDER, pool.map(_build_one, SOURCE_ORDER))
1254
+ )
1255
+
1256
+ # U1: overlay the last research run's per-source outcome onto each record
1257
+ # so the audit layer can tell "configured" from "actually working".
1258
+ # U2: derive each record's four-state audit bucket (probe evidence, when a
1259
+ # live probe runs, is layered on in run() before render).
1260
+ evidence = load_run_evidence(config)
1261
+ for source, record in sources.items():
1262
+ record["run_outcome"] = evidence["outcomes"].get(source) if evidence["fresh"] else None
1263
+ record["audit_state"] = audit_state(source, record, record["run_outcome"])
1264
+
1265
+ # U3: annotate each CLI-dependent source with its binary's health so the
1266
+ # per-source marker and the dedicated CLI-health block can render (R2).
1267
+ for source, cli_name in CLI_DEPENDENCIES.items():
1268
+ record = sources.get(source)
1269
+ if record is None:
1270
+ continue
1271
+ try:
1272
+ probe = health.probe_dependency(cli_name)
1273
+ except Exception:
1274
+ continue
1275
+ record["cli"] = {
1276
+ "name": cli_name,
1277
+ "status": probe.status,
1278
+ "off_path": bool(getattr(probe, "off_path", False)),
1279
+ "detail": probe.detail,
1280
+ "optional": source in _OPTIONAL_CLI_SOURCES,
1281
+ }
1282
+
1283
+ # U7: attach backup + comment sub-lanes to their parent source.
1284
+ for source, record in sources.items():
1285
+ backups, comments = _sub_lanes_for(source, config)
1286
+ if backups:
1287
+ record["backups"] = backups
1288
+ if comments is not None:
1289
+ record["comments"] = comments
1290
+
1291
+ # Sequential on purpose: the permission preflight composes pipeline
1292
+ # diagnostics and must not race the source builders.
1293
+ try:
1294
+ permissions = _permissions_block(config)
1295
+ except Exception as exc:
1296
+ permissions = {"status": "unavailable", "error": f"{type(exc).__name__}: {exc}"}
1297
+
1298
+ return {
1299
+ "engine_version": _engine_version(),
1300
+ "config": {
1301
+ "global_env": str(env.CONFIG_FILE) if env.CONFIG_FILE else None,
1302
+ "config_source": config.get("_CONFIG_SOURCE"),
1303
+ # The resolved host value, so a missing export is visible.
1304
+ "host": env.x_policy(config).host or None,
1305
+ # A .env line cannot declare the X connector lane.
1306
+ "host_lane_file_ignored": bool(config.get("_X_HOST_LANE_FILE_IGNORED")),
1307
+ },
1308
+ "setup": _setup_block(config),
1309
+ "permissions": permissions,
1310
+ "sources": sources,
1311
+ "mode": "config",
1312
+ "run_evidence": {
1313
+ "present": evidence["present"],
1314
+ "fresh": evidence["fresh"],
1315
+ "topic": evidence["topic"],
1316
+ "at": evidence["at"],
1317
+ },
1318
+ }
1319
+
1320
+
1321
+ # ---------------------------------------------------------------------------
1322
+ # Renderers
1323
+ # ---------------------------------------------------------------------------
1324
+
1325
+ def render_json(report: Dict[str, Any]) -> str:
1326
+ return json.dumps(report, indent=2, sort_keys=True)
1327
+
1328
+
1329
+ def _cli_marker(record: Dict[str, Any]) -> str:
1330
+ """Inline `[CLI: name ✓]` / `[keyless]` marker (populated by U3)."""
1331
+ cli = record.get("cli")
1332
+ if not cli:
1333
+ return ""
1334
+ name = cli.get("name")
1335
+ if cli.get("status") == health.OK:
1336
+ return f" [CLI: {name} ✓]"
1337
+ if cli.get("off_path"):
1338
+ return f" [CLI: {name} ✗ off-PATH]"
1339
+ return f" [CLI: {name} ✗ {cli.get('status')}]"
1340
+
1341
+
1342
+ def _run_evidence_suffix(record: Dict[str, Any], state: str) -> str:
1343
+ """Last-run outcome tail for a source line (R4)."""
1344
+ ro = record.get("run_outcome")
1345
+ if not ro:
1346
+ if state == AUDIT_UNVERIFIED:
1347
+ return " [no recent run]"
1348
+ return ""
1349
+ st = ro.get("state")
1350
+ count = ro.get("items_returned") or 0
1351
+ detail = ro.get("detail")
1352
+ if st in _RUN_WORKING_STATES:
1353
+ if count:
1354
+ return f" [✓ {count} items last run]"
1355
+ return " [✓ ran clean, 0 matches last run]"
1356
+ if st == health.PARTIAL:
1357
+ tail = f" ({detail})" if detail else ""
1358
+ return f" [⚠ partial last run{tail}]"
1359
+ tail = detail or st
1360
+ return f" [✕ {tail} last run]"
1361
+
1362
+
1363
+ def _audit_source_line(name: str, record: Dict[str, Any], state: str) -> str:
1364
+ glyph = AUDIT_GLYPHS.get(state, "?")
1365
+ parts = [f" {glyph} {name}{_cli_marker(record)}"]
1366
+ descriptors: List[str] = []
1367
+ if record.get("status") not in (health.OK,):
1368
+ descriptors.append(record["status"])
1369
+ if record.get("note"):
1370
+ descriptors.append(record["note"])
1371
+ elif record.get("detail") and record.get("tier") != TIER_OK:
1372
+ descriptors.append(record["detail"])
1373
+ if descriptors:
1374
+ parts.append(" — " + "; ".join(descriptors))
1375
+ evidence = _run_evidence_suffix(record, state)
1376
+ if evidence:
1377
+ parts.append(evidence)
1378
+ # fix is only ever populated when there is something actionable, so
1379
+ # render it whenever present — an ok-tier record can carry one (the
1380
+ # youtube transcription-key note) and must not lose it in text mode.
1381
+ if record.get("fix"):
1382
+ parts.append(f"; fix: {record['fix']}")
1383
+ # Backup / comment sub-lanes render on their own indented lines (U7),
1384
+ # after the primary line (with its fix) is complete.
1385
+ for sub in _sub_lane_lines(record):
1386
+ parts.append("\n" + sub)
1387
+ return "".join(parts)
1388
+
1389
+
1390
+ def _sub_lane_lines(record: Dict[str, Any]) -> List[str]:
1391
+ """Indented backup/comment sub-lane lines under a source (R8, R9)."""
1392
+ lines: List[str] = []
1393
+ for backup in record.get("backups") or []:
1394
+ state = "armed" if backup.get("armed") else "off"
1395
+ note = f" - {backup['note']}" if backup.get("note") else ""
1396
+ lines.append(f" backup: {backup['name']} — {state}{note}")
1397
+ comments = record.get("comments")
1398
+ if comments is not None:
1399
+ state = "on" if comments.get("enabled") else "off"
1400
+ lines.append(f" comments: {state}")
1401
+ return lines
1402
+
1403
+
1404
+ def _cli_health_lines(report: Dict[str, Any]) -> List[str]:
1405
+ """Dedicated CLI-health block (R2): one row per CLI-dependent source,
1406
+ plus a note naming the keyless sources that need no CLI at all.
1407
+ """
1408
+ sources = report.get("sources") or {}
1409
+ rows: List[str] = []
1410
+ for source in SOURCE_ORDER:
1411
+ cli = (sources.get(source) or {}).get("cli")
1412
+ if not cli:
1413
+ continue
1414
+ ok = cli.get("status") == health.OK
1415
+ glyph = "✓" if ok else "✗"
1416
+ detail = cli.get("detail") or cli.get("status")
1417
+ tail = ""
1418
+ if not ok:
1419
+ if cli.get("off_path"):
1420
+ tail = " (installed off-PATH)"
1421
+ elif cli.get("optional"):
1422
+ tail = " (optional)"
1423
+ rows.append(f" {glyph} {cli['name']} — {source}{tail}: {detail}")
1424
+ if not rows:
1425
+ return []
1426
+ return (
1427
+ ["CLI health (downloaded binaries):"]
1428
+ + rows
1429
+ + [" · Reddit, Hacker News, Polymarket need no CLI (keyless)"]
1430
+ )
1431
+
1432
+
1433
+ def render_text(report: Dict[str, Any]) -> str:
1434
+ lines: List[str] = [f"last30days doctor — engine v{report['engine_version']}"]
1435
+ config_block = report.get("config") or {}
1436
+ if config_block.get("global_env"):
1437
+ line = f"config: {config_block['global_env']}"
1438
+ if config_block.get("config_source"):
1439
+ line += f" (source: {config_block['config_source']})"
1440
+ lines.append(line)
1441
+ host = config_block.get("host")
1442
+ if host:
1443
+ lines.append(f"host: {host} ({env.X_HOST_VAR})")
1444
+ else:
1445
+ lines.append(f"host: not set ({env.X_HOST_VAR} unset; default X policy)")
1446
+ if config_block.get("host_lane_file_ignored"):
1447
+ lines.append(
1448
+ f"note: a {env.X_HOST_LANE_VAR} line in .env cannot declare the X "
1449
+ "connector lane (ignored); export it in the process environment "
1450
+ "for the session instead"
1451
+ )
1452
+
1453
+ setup = report.get("setup") or {}
1454
+ present = sorted(
1455
+ name for name, is_set in (setup.get("keys_present") or {}).items() if is_set
1456
+ )
1457
+ setup_state = "complete" if setup.get("setup_complete") else "not recorded"
1458
+ lines.append(
1459
+ f"setup: {setup_state}; credentials present: "
1460
+ + (", ".join(present) if present else "none")
1461
+ + " (values never shown)"
1462
+ )
1463
+ templated = setup.get("unsubstituted_templates") or []
1464
+ if templated:
1465
+ lines.append(
1466
+ "note: unsubstituted config template(s), counted as unset: "
1467
+ + ", ".join(templated)
1468
+ + " (set a real value or remove each)"
1469
+ )
1470
+
1471
+ permissions = report.get("permissions") or {}
1472
+ if permissions.get("status"):
1473
+ browser = ((permissions.get("local_reads") or {}).get("browser_cookies") or {})
1474
+ lines.append(
1475
+ f"permissions: {permissions['status']}"
1476
+ + (f"; browser cookies: {browser.get('status')}" if browser else "")
1477
+ )
1478
+
1479
+ run_ev = report.get("run_evidence") or {}
1480
+ if run_ev.get("present") and run_ev.get("fresh"):
1481
+ topic = run_ev.get("topic") or "last run"
1482
+ lines.append(
1483
+ f"last run: {topic} - overlaying actual source outcomes below"
1484
+ )
1485
+ elif run_ev.get("present"):
1486
+ lines.append(
1487
+ "last run: found but stale - run `doctor --postmortem` to inspect it"
1488
+ )
1489
+
1490
+ grouped: Dict[str, List[str]] = {state: [] for state, _ in AUDIT_GROUPS}
1491
+ for name, record in (report.get("sources") or {}).items():
1492
+ state = record.get("audit_state") or audit_state(
1493
+ name, record, record.get("run_outcome")
1494
+ )
1495
+ grouped.setdefault(state, []).append(_audit_source_line(name, record, state))
1496
+
1497
+ for state, header in AUDIT_GROUPS:
1498
+ entries = grouped.get(state) or []
1499
+ lines.append("")
1500
+ lines.append(f"{AUDIT_GLYPHS.get(state, '')} {header}:")
1501
+ if entries:
1502
+ lines.extend(entries)
1503
+ else:
1504
+ lines.append(" (none)")
1505
+
1506
+ cli_block = _cli_health_lines(report)
1507
+ if cli_block:
1508
+ lines.append("")
1509
+ lines.extend(cli_block)
1510
+
1511
+ lines.append("")
1512
+ lines.append(
1513
+ "doctor reports problems without failing; run the printed fixes, "
1514
+ "then re-run doctor"
1515
+ )
1516
+ return "\n".join(lines) + "\n"
1517
+
1518
+
1519
+ # ---------------------------------------------------------------------------
1520
+ # Post-mortem (U4 / R5): what actually happened on the last run
1521
+ #
1522
+ # Unlike plain doctor (config prediction), --postmortem reads the last run's
1523
+ # per-source SourceOutcome and reports what broke, at any age (labeled). It is
1524
+ # a reader of the same last-report.json the overlay uses - no new persistence.
1525
+ # ---------------------------------------------------------------------------
1526
+
1527
+ def _age_label(iso: Any) -> str:
1528
+ if not iso:
1529
+ return ""
1530
+ try:
1531
+ ts = datetime.datetime.fromisoformat(iso)
1532
+ except (TypeError, ValueError):
1533
+ return ""
1534
+ if ts.tzinfo is None:
1535
+ ts = ts.replace(tzinfo=datetime.timezone.utc)
1536
+ secs = int(
1537
+ (datetime.datetime.now(datetime.timezone.utc) - ts).total_seconds()
1538
+ )
1539
+ if secs < 0:
1540
+ return ""
1541
+ if secs < 3600:
1542
+ return f"{secs // 60}m ago"
1543
+ if secs < 86400:
1544
+ return f"{secs // 3600}h ago"
1545
+ return f"{secs // 86400}d ago"
1546
+
1547
+
1548
+ def build_postmortem(config: Dict[str, Any]) -> Dict[str, Any]:
1549
+ """Assemble the last run's per-source outcomes (any age) for --postmortem."""
1550
+ evidence = load_run_evidence(config)
1551
+ return {
1552
+ "engine_version": _engine_version(),
1553
+ "mode": "postmortem",
1554
+ "present": evidence["present"],
1555
+ "topic": evidence["topic"],
1556
+ "at": evidence["at"],
1557
+ "outcomes": evidence["outcomes"],
1558
+ }
1559
+
1560
+
1561
+ def _postmortem_state_label(source: str, outcome: Dict[str, Any]) -> str:
1562
+ """State label for a failed post-mortem line.
1563
+
1564
+ ``payment-required`` reads as "credits exhausted" ("X API credits
1565
+ exhausted" for X) so the fix is obvious at a glance: top up, not re-login.
1566
+ Every other state prints as its raw name.
1567
+ """
1568
+ state = str(outcome.get("state") or "")
1569
+ if state == health.PAYMENT_REQUIRED:
1570
+ return health.credits_exhausted_label(source)
1571
+ return state
1572
+
1573
+
1574
+ def render_postmortem_text(pm: Dict[str, Any]) -> str:
1575
+ lines = [f"last30days post-mortem — engine v{pm['engine_version']}"]
1576
+ if not pm.get("present"):
1577
+ lines.append("")
1578
+ lines.append(
1579
+ "No saved run found - run `/last30days <topic>` first, or "
1580
+ "`doctor --probe` for a live check."
1581
+ )
1582
+ return "\n".join(lines) + "\n"
1583
+ topic = pm.get("topic") or "last run"
1584
+ age = _age_label(pm.get("at"))
1585
+ lines.append(f"last run: {topic}" + (f" ({age})" if age else ""))
1586
+
1587
+ failed, partial, succeeded, skipped = [], [], [], []
1588
+ for source, outcome in (pm.get("outcomes") or {}).items():
1589
+ state = outcome.get("state")
1590
+ if state in _RUN_WORKING_STATES:
1591
+ succeeded.append((source, outcome))
1592
+ elif state == health.PARTIAL:
1593
+ partial.append((source, outcome))
1594
+ elif state == health.SKIPPED_UNCONFIGURED:
1595
+ skipped.append((source, outcome))
1596
+ else:
1597
+ failed.append((source, outcome))
1598
+
1599
+ if failed:
1600
+ lines.append("")
1601
+ lines.append("Failed:")
1602
+ for source, outcome in failed:
1603
+ detail = outcome.get("detail") or outcome.get("state")
1604
+ lines.append(f" ✕ {source} — {_postmortem_state_label(source, outcome)}: {detail}")
1605
+ if outcome.get("fix_hint"):
1606
+ lines.append(f" fix: {outcome['fix_hint']}")
1607
+ if partial:
1608
+ lines.append("")
1609
+ lines.append("Partial:")
1610
+ for source, outcome in partial:
1611
+ count = outcome.get("items_returned") or 0
1612
+ detail = outcome.get("detail")
1613
+ tail = f" — {detail}" if detail else ""
1614
+ lines.append(f" ⚠ {source} ({count} items){tail}")
1615
+ if outcome.get("fix_hint"):
1616
+ lines.append(f" fix: {outcome['fix_hint']}")
1617
+ if succeeded:
1618
+ lines.append("")
1619
+ def _succeeded_label(source: str, outcome: Dict[str, Any]) -> str:
1620
+ count = outcome.get("items_returned") or 0
1621
+ detail = outcome.get("detail")
1622
+ if detail:
1623
+ noun = "item" if count == 1 else "items"
1624
+ return f"{source} ({count} {noun}; {detail})"
1625
+ return f"{source} ({count})"
1626
+
1627
+ names = ", ".join(_succeeded_label(s, o) for s, o in succeeded)
1628
+ lines.append(f"Succeeded: {names}")
1629
+ if skipped:
1630
+ lines.append("")
1631
+ lines.append(
1632
+ "Skipped (not configured): " + ", ".join(s for s, _ in skipped)
1633
+ )
1634
+ if not failed and not partial:
1635
+ lines.append("")
1636
+ lines.append("No failures on the last run.")
1637
+ return "\n".join(lines) + "\n"
1638
+
1639
+
1640
+ # ---------------------------------------------------------------------------
1641
+ # Cross-invocation cache (U5 / R5, KTD 8)
1642
+ #
1643
+ # Doctor writes its JSON result beside the existing ``last-run.json``
1644
+ # convention (env.CONFIG_DIR) so the SKILL.md standing rule's pre-research
1645
+ # check costs one file read on the healthy path instead of a dozen probe
1646
+ # subprocesses. ``--cached`` serves the stored report within the TTL and
1647
+ # falls through to a live run (rewriting the cache) when the file is stale,
1648
+ # absent, or corrupt — corruption is treated as absence, never a crash.
1649
+ # An explicit ``doctor`` (no ``--cached``) always runs live and refreshes.
1650
+ #
1651
+ # The payload carries a schema stamp (mirrors REPORT_CACHE_VERSION in
1652
+ # last30days.py) and a config fingerprint — a sha256 over the same
1653
+ # non-secret signals doctor already reports (key-presence booleans, backend
1654
+ # pin values, INCLUDE_SOURCES). A schema or fingerprint mismatch is treated
1655
+ # as stale, so a credential or pin change can never serve yesterday's
1656
+ # conclusions. Served reports carry ``from_cache`` + ``generated_at`` so
1657
+ # consumers can see staleness instead of inferring it.
1658
+ # ---------------------------------------------------------------------------
1659
+
1660
+ CACHE_FILENAME = "doctor-cache.json"
1661
+
1662
+ # Bump when the cached payload/report shape changes incompatibly; a
1663
+ # mismatched (or absent) stamp is treated as an absent cache.
1664
+ DOCTOR_CACHE_SCHEMA_VERSION = "last30days-doctor-cache/v1"
1665
+
1666
+ # TTL in SECONDS (env-tunable via LAST30DAYS_DOCTOR_TTL; registered in
1667
+ # lib/env.py's get_config key list so a .env-set value is not swallowed).
1668
+ DEFAULT_CACHE_TTL_SECONDS = 900
1669
+
1670
+ # Config vars whose values must never land in the cache file. Doctor output
1671
+ # carries no secrets by design (key presence is booleans only); this belt-and-
1672
+ # suspenders check refuses to persist the cache if a seeded value ever leaks.
1673
+ _SECRET_CONFIG_VARS = KEY_PRESENCE_VARS + (
1674
+ "AUTH_TOKEN", "CT0", "APIFY_API_TOKEN", "GOOGLE_GENAI_API_KEY",
1675
+ )
1676
+
1677
+ # Backend pin vars folded into the config fingerprint. Pin values are
1678
+ # backend names (e.g. "bird"), never secrets. The host key and the X
1679
+ # connector lane signal join them: both are non-secret switches that
1680
+ # change every X conclusion, so a cached report must not outlive them.
1681
+ _FINGERPRINT_PIN_VARS = (
1682
+ env.X_BACKEND_PIN_VAR,
1683
+ env.REDDIT_BACKEND_PIN_VAR,
1684
+ env.X_HOST_VAR,
1685
+ env.X_HOST_LANE_VAR,
1686
+ )
1687
+
1688
+ # Top-level report keys the renderers read unguarded; a cached report
1689
+ # missing any of them is treated as corrupt (absent), never rendered.
1690
+ _REQUIRED_REPORT_KEYS = ("engine_version", "config", "setup", "permissions", "sources")
1691
+
1692
+
1693
+ def cache_path() -> Optional[Path]:
1694
+ """The doctor cache file, beside last-run.json (None in clean mode)."""
1695
+ if env.CONFIG_DIR is None:
1696
+ return None
1697
+ return env.CONFIG_DIR / CACHE_FILENAME
1698
+
1699
+
1700
+ def cache_ttl_seconds(config: Dict[str, Any]) -> int:
1701
+ """LAST30DAYS_DOCTOR_TTL in seconds; process env > config; default 900."""
1702
+ raw: Any = os.environ.get("LAST30DAYS_DOCTOR_TTL")
1703
+ if raw is None:
1704
+ raw = (config or {}).get("LAST30DAYS_DOCTOR_TTL")
1705
+ if raw is None or raw == "":
1706
+ return DEFAULT_CACHE_TTL_SECONDS
1707
+ try:
1708
+ return max(0, int(raw))
1709
+ except (TypeError, ValueError):
1710
+ return DEFAULT_CACHE_TTL_SECONDS
1711
+
1712
+
1713
+ def _is_fresh(timestamp: Any, ttl_seconds: int) -> bool:
1714
+ return env.is_timestamp_fresh(timestamp, ttl_seconds)
1715
+
1716
+
1717
+ def _config_fingerprint(config: Dict[str, Any]) -> str:
1718
+ """sha256 over the non-secret config signals doctor already reports.
1719
+
1720
+ Inputs are key-presence BOOLEANS (never credential values — the same
1721
+ ``keys_present`` set the setup block renders), backend pin values
1722
+ (backend names, not secrets), and INCLUDE_SOURCES (not a secret).
1723
+ Adding or removing a credential, changing a pin, or toggling an opt-in
1724
+ source yields a new fingerprint, so ``read_cached_report`` treats the
1725
+ old cache as stale instead of serving pre-change conclusions.
1726
+ """
1727
+ config = config or {}
1728
+ signals = {
1729
+ "keys_present": _setup_block(config)["keys_present"],
1730
+ "pins": {var: str(config.get(var) or "") for var in _FINGERPRINT_PIN_VARS},
1731
+ "include_sources": str(config.get("INCLUDE_SOURCES") or ""),
1732
+ "x_cookie_access_denied": str(config.get("LAST30DAYS_X_COOKIE_ACCESS_DENIED") or ""),
1733
+ "browser_consent": str(config.get("BROWSER_CONSENT") or ""),
1734
+ "from_browser": str(config.get("FROM_BROWSER") or ""),
1735
+ }
1736
+ canonical = json.dumps(signals, sort_keys=True, separators=(",", ":"))
1737
+ return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
1738
+
1739
+
1740
+ def _report_shape_ok(report: Any) -> bool:
1741
+ """True when a cached report satisfies the render contract.
1742
+
1743
+ Validates everything the renderers read unguarded: the required
1744
+ top-level keys exist (dict-valued where render calls ``.get`` on them),
1745
+ and every sources record is a dict carrying a known tier and a str
1746
+ status. Anything else is corrupt — treated as absent, never rendered.
1747
+ """
1748
+ if not isinstance(report, dict):
1749
+ return False
1750
+ if any(key not in report for key in _REQUIRED_REPORT_KEYS):
1751
+ return False
1752
+ if any(
1753
+ not isinstance(report[key], dict)
1754
+ for key in ("config", "setup", "permissions", "sources")
1755
+ ):
1756
+ return False
1757
+ sources = report["sources"]
1758
+ if not sources:
1759
+ return False
1760
+ for record in sources.values():
1761
+ if not isinstance(record, dict):
1762
+ return False
1763
+ if record.get("tier") not in GLYPHS:
1764
+ return False
1765
+ if not isinstance(record.get("status"), str):
1766
+ return False
1767
+ return True
1768
+
1769
+
1770
+ def read_cached_report(config: Dict[str, Any]) -> Optional[Dict[str, Any]]:
1771
+ """Return the cached report when present, well-formed, and within TTL.
1772
+
1773
+ Any failure mode — unreadable file, invalid JSON, schema mismatch,
1774
+ config-fingerprint mismatch, wrong shape, bad or stale timestamp —
1775
+ returns None (cache treated as absent, never a crash).
1776
+
1777
+ A served report is stamped with ``from_cache: True`` and
1778
+ ``generated_at`` (the cache write time) so consumers see staleness.
1779
+ """
1780
+ path = cache_path()
1781
+ if path is None:
1782
+ return None
1783
+ try:
1784
+ payload = json.loads(path.read_text(encoding="utf-8"))
1785
+ except Exception:
1786
+ return None
1787
+ if not isinstance(payload, dict):
1788
+ return None
1789
+ if payload.get("schema") != DOCTOR_CACHE_SCHEMA_VERSION:
1790
+ return None # absent or mismatched schema stamp: treat as absent
1791
+ if payload.get("fingerprint") != _config_fingerprint(config):
1792
+ return None # credentials/pins/opt-ins changed: cache is stale
1793
+ report = payload.get("report")
1794
+ if not _report_shape_ok(report):
1795
+ return None
1796
+ if not _is_fresh(payload.get("timestamp"), cache_ttl_seconds(config)):
1797
+ return None
1798
+ report["generated_at"] = payload.get("timestamp")
1799
+ report["from_cache"] = True
1800
+ return report
1801
+
1802
+
1803
+ def _write_cache(report: Dict[str, Any], config: Dict[str, Any]) -> bool:
1804
+ """Best-effort cache write; refuses to persist any secret value.
1805
+
1806
+ Never fatal: any failure returns False after a one-line stderr warning
1807
+ (doctor's exit-0 contract is unaffected; only ``--cached`` reuse is).
1808
+ """
1809
+ try:
1810
+ path = cache_path()
1811
+ if path is None:
1812
+ return False
1813
+ payload = {
1814
+ "schema": DOCTOR_CACHE_SCHEMA_VERSION,
1815
+ "fingerprint": _config_fingerprint(config),
1816
+ "timestamp": report.get("generated_at")
1817
+ or datetime.datetime.now(datetime.timezone.utc).isoformat(),
1818
+ "report": report,
1819
+ }
1820
+ raw = json.dumps(payload, indent=2, sort_keys=True)
1821
+ for var in _SECRET_CONFIG_VARS:
1822
+ value = (config or {}).get(var)
1823
+ if isinstance(value, str) and value and value in raw:
1824
+ return False # never write a cache containing a secret
1825
+ path.parent.mkdir(parents=True, exist_ok=True)
1826
+ path.write_text(raw, encoding="utf-8")
1827
+ return True
1828
+ except Exception as exc:
1829
+ sys.stderr.write(
1830
+ f"[last30days] WARNING: could not write doctor cache: "
1831
+ f"{type(exc).__name__}: {exc}\n"
1832
+ )
1833
+ sys.stderr.flush()
1834
+ return False
1835
+
1836
+
1837
+ # ---------------------------------------------------------------------------
1838
+ # Live probe (U5 / R6)
1839
+ #
1840
+ # When there is no fresh run to learn from (or on explicit --probe), doctor
1841
+ # runs a BOUNDED live test so WORKING is verified, not guessed. Scope is
1842
+ # deliberate: free HTTP endpoints + keyless CLIs only. Credit-gated /
1843
+ # session-gated sources (x, tiktok, instagram, threads, ...) are NOT
1844
+ # live-probed - a health check must never spend ScrapeCreators credits or trip
1845
+ # auth rate limits; they stay UNVERIFIED with that noted. Every probe is capped
1846
+ # by a per-source deadline so a single slow source (YouTube's 120s search) can
1847
+ # never hang doctor.
1848
+ # ---------------------------------------------------------------------------
1849
+
1850
+ # Free, keyless liveness endpoints (reachability check, tiny payload).
1851
+ _HTTP_PROBE_URLS = {
1852
+ # The keyless engine's real discovery endpoint, built by the lane itself so
1853
+ # the probe cannot drift from what the engine requests (hand-copied URLs
1854
+ # kept certifying endpoints the engine had abandoned).
1855
+ "reddit": reddit_search.search_url("test"),
1856
+ "hackernews": "https://hn.algolia.com/api/v1/search?query=test&hitsPerPage=1",
1857
+ "polymarket": "https://gamma-api.polymarket.com/events?limit=1",
1858
+ "github": "https://api.github.com/rate_limit",
1859
+ }
1860
+
1861
+ # Per-source exception to "a 4xx still means the endpoint responded". The
1862
+ # keyless Reddit lanes send no credentials, so a 403/429 there is the host
1863
+ # refusing this client — the exact failure the engine hits — not reachability.
1864
+ _PROBE_BLOCKED_STATUSES = {"reddit": frozenset({403, 429})}
1865
+
1866
+ # Blocked statuses that mean "refused this probe right now", not "the source is
1867
+ # down". Reddit answers a burst of keyless probes with 429 while the research
1868
+ # lane — which retries with backoff across several endpoints — serves the same
1869
+ # query fine. A single unretried 429 was reporting a healthy Reddit as NOT
1870
+ # WORKING, so these get one retry and, if still refused, downgrade to unverified
1871
+ # rather than a false outage. 403 stays hard: that is a real keyless block.
1872
+ _PROBE_TRANSIENT_STATUSES = {"reddit": frozenset({429})}
1873
+
1874
+ # One retry only: the probe budget is the per-source deadline, and a source that
1875
+ # rate-limits twice in a row is worth surfacing as unverified.
1876
+ _PROBE_RETRY_DELAY_SECONDS = 2.0
1877
+
1878
+ # Probe with the identity the lane sends, or the probe measures the User-Agent
1879
+ # rather than the endpoint (get_text sends http.BROWSER_USER_AGENT).
1880
+ _PROBE_HEADERS = {
1881
+ "reddit": {
1882
+ "User-Agent": http.BROWSER_USER_AGENT,
1883
+ "Accept": "text/html",
1884
+ },
1885
+ }
1886
+
1887
+ # Per-source body validators, run on a 2xx: None means the body is the page the
1888
+ # lane parses, anything else is the reason it is not. Reddit answers a blocked
1889
+ # keyless client with a 200 challenge page, so status alone reads it as working.
1890
+ _PROBE_BODY_CHECKS: Dict[str, Callable[[str], Optional[str]]] = {
1891
+ "reddit": reddit_search.unrecognized_body,
1892
+ }
1893
+
1894
+ DEFAULT_PROBE_TIMEOUT_SECONDS = 10
1895
+
1896
+
1897
+ def probe_timeout_seconds(config: Dict[str, Any]) -> int:
1898
+ """Per-source probe deadline; process env > config > default 10s."""
1899
+ raw: Any = os.environ.get("LAST30DAYS_DOCTOR_PROBE_TIMEOUT")
1900
+ if raw is None:
1901
+ raw = (config or {}).get("LAST30DAYS_DOCTOR_PROBE_TIMEOUT")
1902
+ if raw is None or raw == "":
1903
+ return DEFAULT_PROBE_TIMEOUT_SECONDS
1904
+ try:
1905
+ return max(1, int(raw))
1906
+ except (TypeError, ValueError):
1907
+ return DEFAULT_PROBE_TIMEOUT_SECONDS
1908
+
1909
+
1910
+ def _probeable_sources() -> tuple:
1911
+ """Sources doctor will live-probe: free HTTP endpoints + keyless CLIs.
1912
+
1913
+ github is HTTP-probed (its REST tier works without gh), so it is excluded
1914
+ from the CLI-probe path even though gh is in CLI_DEPENDENCIES.
1915
+ """
1916
+ cli_only = [s for s in CLI_DEPENDENCIES if s not in _HTTP_PROBE_URLS]
1917
+ return tuple(dict.fromkeys(list(_HTTP_PROBE_URLS) + cli_only))
1918
+
1919
+
1920
+ def _http_ok(
1921
+ url: str,
1922
+ timeout: float,
1923
+ *,
1924
+ blocked_statuses: frozenset = frozenset(),
1925
+ headers: Optional[Dict[str, str]] = None,
1926
+ body_check: Optional[Callable[[str], Optional[str]]] = None,
1927
+ deadline_monotonic: Optional[float] = None,
1928
+ ) -> tuple:
1929
+ """Reachability check: a 4xx still means the endpoint responded; 5xx or a
1930
+ connection/timeout error means it did not.
1931
+
1932
+ ``blocked_statuses`` names the per-source codes that mean "responded, but
1933
+ refused us" (Reddit's keyless 403/429) — those are a failure, not
1934
+ reachability. ``headers`` overrides the probe identity so a source can be
1935
+ probed with the same User-Agent its lane sends. ``body_check`` validates
1936
+ a 2xx body; a non-None reason fails the probe as blocked.
1937
+ """
1938
+ def _verdict(code: int) -> tuple:
1939
+ return code < 500 and code not in blocked_statuses, f"HTTP {code}"
1940
+
1941
+ try:
1942
+ req = urllib.request.Request(
1943
+ url, headers=headers or {"User-Agent": "last30days-doctor"}
1944
+ )
1945
+ if deadline_monotonic is not None:
1946
+ from . import bounded_get
1947
+
1948
+ code, body, error = bounded_get.get(
1949
+ req, timeout=timeout, deadline_monotonic=deadline_monotonic,
1950
+ read_body=body_check is not None, read_error_body=False,
1951
+ )
1952
+ if error is not None:
1953
+ return _verdict(error.code)
1954
+ if body_check is not None and code < 300:
1955
+ reason = body_check((body or b"").decode("utf-8", errors="replace"))
1956
+ if reason:
1957
+ return False, f"HTTP {code} but blocked: {reason}"
1958
+ return _verdict(code)
1959
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
1960
+ code = getattr(resp, "status", 200) or 200
1961
+ if body_check is not None and code < 300:
1962
+ text = resp.read().decode("utf-8", errors="replace")
1963
+ reason = body_check(text)
1964
+ if reason:
1965
+ return False, f"HTTP {code} but blocked: {reason}"
1966
+ return _verdict(code)
1967
+ except urllib.error.HTTPError as exc:
1968
+ return _verdict(exc.code)
1969
+ except Exception as exc:
1970
+ from . import bounded_get
1971
+
1972
+ if isinstance(exc, bounded_get.GetTimeout):
1973
+ return False, "probe exceeded deadline"
1974
+ return False, f"{type(exc).__name__}: {exc}"
1975
+
1976
+
1977
+ def _transient_probe_detail(name: str, detail: str) -> bool:
1978
+ """True when ``detail`` is a status this source may refuse us transiently.
1979
+
1980
+ ``_http_ok`` renders its verdict as ``HTTP {code}``; both live in this module,
1981
+ so matching that shape here keeps the retry rule next to the codes it covers.
1982
+ """
1983
+ transient = _PROBE_TRANSIENT_STATUSES.get(name)
1984
+ if not transient:
1985
+ return False
1986
+ return any(detail == f"HTTP {code}" for code in transient)
1987
+
1988
+
1989
+ def _probe_source(name: str, config: Dict[str, Any], timeout: float) -> Optional[Dict[str, Any]]:
1990
+ deadline = time.monotonic() + timeout
1991
+ expired = {"ok": False, "detail": "probe exceeded deadline", "probed": True}
1992
+ if timeout <= 0:
1993
+ return expired
1994
+ url = _HTTP_PROBE_URLS.get(name)
1995
+ if url:
1996
+ blocked = _PROBE_BLOCKED_STATUSES.get(name, frozenset())
1997
+ headers = _PROBE_HEADERS.get(name)
1998
+ probe_kwargs = {
1999
+ "blocked_statuses": blocked,
2000
+ "headers": headers,
2001
+ "body_check": _PROBE_BODY_CHECKS.get(name),
2002
+ "deadline_monotonic": deadline,
2003
+ }
2004
+ ok, detail = _http_ok(url, timeout, **probe_kwargs)
2005
+ if time.monotonic() >= deadline or detail == "probe exceeded deadline":
2006
+ return expired
2007
+ if not ok and _transient_probe_detail(name, detail):
2008
+ remaining = deadline - time.monotonic()
2009
+ if remaining <= 0:
2010
+ return expired
2011
+ if remaining <= _PROBE_RETRY_DELAY_SECONDS:
2012
+ return {
2013
+ "ok": False,
2014
+ "transient": True,
2015
+ "detail": f"{detail} (retry skipped: insufficient probe budget)",
2016
+ "probed": True,
2017
+ }
2018
+ time.sleep(_PROBE_RETRY_DELAY_SECONDS)
2019
+ remaining = deadline - time.monotonic()
2020
+ if remaining <= 0:
2021
+ return expired
2022
+ ok, detail = _http_ok(url, remaining, **probe_kwargs)
2023
+ if time.monotonic() >= deadline or detail == "probe exceeded deadline":
2024
+ return expired
2025
+ if not ok and _transient_probe_detail(name, detail):
2026
+ return {
2027
+ "ok": False,
2028
+ "transient": True,
2029
+ "detail": f"{detail} (rate-limited twice; the lane retries with backoff)",
2030
+ "probed": True,
2031
+ }
2032
+ return {"ok": ok, "detail": detail, "probed": True}
2033
+ cli = CLI_DEPENDENCIES.get(name)
2034
+ if cli:
2035
+ try:
2036
+ probe = health.probe_dependency(cli, timeout=max(0, deadline - time.monotonic()))
2037
+ if time.monotonic() >= deadline:
2038
+ return expired
2039
+ except Exception as exc:
2040
+ return {"ok": False, "detail": f"{type(exc).__name__}: {exc}", "probed": True}
2041
+ return {"ok": bool(probe.ok), "detail": probe.detail, "probed": True}
2042
+ return None
2043
+
2044
+
2045
+ def _probe_sources(config: Dict[str, Any], timeout: int) -> Dict[str, Dict[str, Any]]:
2046
+ """Probe the probeable sources concurrently, each capped at ``timeout``.
2047
+
2048
+ A source that blows its deadline resolves to a probe-failure for that
2049
+ source only (never a hung command); other probes are unaffected.
2050
+ """
2051
+ names = _probeable_sources()
2052
+ results: Dict[str, Dict[str, Any]] = {}
2053
+ with concurrent.futures.ThreadPoolExecutor(
2054
+ max_workers=min(8, len(names) or 1)
2055
+ ) as pool:
2056
+ futures = {
2057
+ name: pool.submit(_probe_source, name, config, timeout) for name in names
2058
+ }
2059
+ for name, fut in futures.items():
2060
+ try:
2061
+ res = fut.result()
2062
+ except concurrent.futures.TimeoutError:
2063
+ res = {"ok": False, "detail": "probe exceeded deadline", "probed": True}
2064
+ except Exception as exc:
2065
+ res = {
2066
+ "ok": False,
2067
+ "detail": f"{type(exc).__name__}: {exc}",
2068
+ "probed": True,
2069
+ }
2070
+ if res is not None:
2071
+ results[name] = res
2072
+ return results
2073
+
2074
+
2075
+ def _apply_probe(report: Dict[str, Any], probe_results: Dict[str, Dict[str, Any]]) -> None:
2076
+ """Attach probe results and re-derive audit_state for probed sources."""
2077
+ for name, res in probe_results.items():
2078
+ record = (report.get("sources") or {}).get(name)
2079
+ if record is None:
2080
+ continue
2081
+ record["probe"] = res
2082
+ record["audit_state"] = audit_state(
2083
+ name, record, record.get("run_outcome"), res
2084
+ )
2085
+
2086
+
2087
+ def run(
2088
+ config: Dict[str, Any],
2089
+ *,
2090
+ emit_json: bool = False,
2091
+ cached: bool = False,
2092
+ postmortem: bool = False,
2093
+ probe: bool = False,
2094
+ ) -> int:
2095
+ """Build (or serve the cached) doctor report and print it. Always exits 0
2096
+ (reporting problems is a successful run).
2097
+
2098
+ ``postmortem=True`` reads the last run's per-source outcomes (any age) and
2099
+ reports what broke, instead of predicting config health.
2100
+ ``probe=True`` (or no fresh run) runs a bounded live probe (U5) so WORKING
2101
+ is verified, not guessed.
2102
+ ``cached=True`` serves the stored report within the TTL; stale, absent,
2103
+ corrupt, schema-mismatched, or fingerprint-mismatched caches fall
2104
+ through to a live run that rewrites the cache — as does ANY exception
2105
+ raised while serving the cache (never-crash contract, KTD 8).
2106
+ ``cached=False`` (explicit ``doctor``) always runs live and refreshes.
2107
+ """
2108
+ if postmortem:
2109
+ pm = build_postmortem(config)
2110
+ if emit_json:
2111
+ print(json.dumps(pm, indent=2, sort_keys=True))
2112
+ else:
2113
+ print(render_postmortem_text(pm), end="")
2114
+ return 0
2115
+
2116
+ def _emit(report: Dict[str, Any]) -> None:
2117
+ if emit_json:
2118
+ # generated_at/from_cache ride the report dict, so they appear
2119
+ # at the JSON top level for free.
2120
+ print(render_json(report))
2121
+ else:
2122
+ # The cache-status line is printed here (run() owns this print)
2123
+ # because render_text's header belongs to the render layer, not
2124
+ # the cache layer.
2125
+ origin = "cached" if report.get("from_cache") else "live"
2126
+ print(render_text(report), end="")
2127
+ print(f"generated: {report.get('generated_at')} ({origin})")
2128
+
2129
+ if cached:
2130
+ try:
2131
+ cached_report = read_cached_report(config)
2132
+ if cached_report is not None:
2133
+ _emit(cached_report)
2134
+ return 0
2135
+ except Exception:
2136
+ # Belt-and-suspenders for shapes the validator misses: any
2137
+ # failure serving the cache falls through to a live run.
2138
+ pass
2139
+ report = build_report(config)
2140
+ report["generated_at"] = datetime.datetime.now(datetime.timezone.utc).isoformat()
2141
+ report["from_cache"] = False
2142
+
2143
+ # U5: verify WORKING with a bounded live probe when asked (--probe) or when
2144
+ # there is no fresh run to learn from ("if no recent runs, run a live
2145
+ # test"). Scoped to free/CLI sources; credit-gated sources stay UNVERIFIED.
2146
+ fresh_run = bool((report.get("run_evidence") or {}).get("fresh"))
2147
+ if probe or not fresh_run:
2148
+ timeout = probe_timeout_seconds(config)
2149
+ probeable = _probeable_sources()
2150
+ sys.stderr.write(
2151
+ f"[last30days] doctor live probe: checking {len(probeable)} free/CLI "
2152
+ f"sources ({timeout}s each; no credit-gated sources - x/tiktok/"
2153
+ f"instagram/threads stay unverified)\n"
2154
+ )
2155
+ sys.stderr.flush()
2156
+ try:
2157
+ probe_results = _probe_sources(config, timeout)
2158
+ except Exception:
2159
+ probe_results = {}
2160
+ _apply_probe(report, probe_results)
2161
+ report["mode"] = "probe"
2162
+ report["probe"] = {"ran": True, "timeout": timeout, "sources": list(probeable)}
2163
+
2164
+ _write_cache(report, config)
2165
+ _emit(report)
2166
+ return 0