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
@@ -17,7 +17,7 @@ import urllib.request
17
17
  from concurrent.futures import ThreadPoolExecutor, as_completed
18
18
  from typing import Any, Dict, List, Optional
19
19
 
20
- from . import dates, log
20
+ from . import dates, env, http, log, schema
21
21
  from .query import extract_core_subject
22
22
  from .relevance import token_overlap_relevance
23
23
 
@@ -50,7 +50,7 @@ def _resolve_token(token: Optional[str] = None) -> Optional[str]:
50
50
  """Resolve GitHub auth token from argument, env, or gh CLI."""
51
51
  if token:
52
52
  return token
53
- env_token = os.environ.get("GITHUB_TOKEN")
53
+ env_token = env.read_secret_env("GITHUB_TOKEN")
54
54
  if env_token:
55
55
  return env_token
56
56
  # Fallback: try gh CLI
@@ -81,8 +81,18 @@ def _fetch_json(
81
81
  url: str,
82
82
  token: Optional[str] = None,
83
83
  timeout: int = 15,
84
+ failure_out: Optional[List[str]] = None,
84
85
  ) -> Optional[Dict[str, Any]]:
85
- """Fetch JSON from GitHub API. Returns None on failure."""
86
+ """Fetch JSON from GitHub API. Returns None on failure.
87
+
88
+ When ``failure_out`` is provided, a short human-readable reason is
89
+ appended for every failure branch so callers can distinguish transport
90
+ failures from genuinely empty results (issue #384).
91
+ """
92
+
93
+ def _note(msg: str) -> None:
94
+ if failure_out is not None:
95
+ failure_out.append(msg)
86
96
  headers = {
87
97
  "User-Agent": USER_AGENT,
88
98
  "Accept": "application/vnd.github+json",
@@ -92,23 +102,28 @@ def _fetch_json(
92
102
 
93
103
  req = urllib.request.Request(url, headers=headers)
94
104
  try:
95
- with urllib.request.urlopen(req, timeout=timeout) as resp:
105
+ with http.open_request(req, timeout) as resp:
96
106
  body = resp.read().decode("utf-8")
97
107
  return json.loads(body)
98
108
  except urllib.error.HTTPError as e:
99
109
  if e.code == 403:
100
110
  _log(f"403 rate limited or forbidden: {url}")
111
+ _note("HTTP 403: rate limited or forbidden")
101
112
  return None
102
113
  if e.code == 422:
103
114
  _log(f"422 unprocessable: {url}")
115
+ _note("HTTP 422: unprocessable query")
104
116
  return None
105
117
  _log(f"HTTP {e.code}: {e.reason}")
118
+ _note(f"HTTP {e.code}: {e.reason}")
106
119
  return None
107
120
  except (urllib.error.URLError, OSError, TimeoutError) as e:
108
121
  _log(f"Network error: {e}")
122
+ _note(f"network error: {e}")
109
123
  return None
110
124
  except json.JSONDecodeError as e:
111
125
  _log(f"JSON decode error: {e}")
126
+ _note(f"invalid JSON: {e}")
112
127
  return None
113
128
 
114
129
 
@@ -151,6 +166,79 @@ def _compute_relevance(
151
166
  return round(relevance, 2)
152
167
 
153
168
 
169
+ # GitHub search qualifiers the planner sometimes writes straight into the topic
170
+ # string (e.g. "open source AI stars:>1000 created:>2025-03-20"). They must not
171
+ # reach the query builder in `search_github`: it appends its own
172
+ # `created:>{from_date}`, and when two `created:` qualifiers collide GitHub
173
+ # honours the FIRST and silently ignores ours. The API then returns
174
+ # out-of-window items that `parse_github_response`'s date filter drops
175
+ # wholesale — a source that fetches results and reports zero (issue #949).
176
+ # Qualifiers may also arrive fully wrapped in one pair of parens/brackets/
177
+ # quotes ("(created:>2025-03-20)", '"stars:>1000"'), which an LLM planner
178
+ # plausibly emits; _WRAPPED_QUALIFIER_RE consumes the whole wrapper pair with
179
+ # the qualifier so no stray `()`/`""` residue reaches the query (issue #952).
180
+ # The plain regex's boundary also accepts wrapper openers so a qualifier with
181
+ # a missing closer ("(created:>2025-03-20") is still stripped rather than
182
+ # leaking into the query; only the stray opener survives, harmlessly.
183
+ QUALIFIER_KEYS = frozenset({
184
+ "archived", "assignee", "author", "base", "closed", "comments", "commenter",
185
+ "created", "fork", "forks", "head", "in", "interactions", "involves", "is",
186
+ "label", "language", "license", "linked", "mentions", "merged", "milestone",
187
+ "no", "org", "project", "pushed", "reactions", "repo", "review",
188
+ "review-requested", "reviewed-by", "size", "sort", "stars", "state", "team",
189
+ "topic", "topics", "type", "updated", "user",
190
+ })
191
+
192
+ _QUALIFIER_KEYS_ALT = "|".join(sorted(QUALIFIER_KEYS))
193
+
194
+ _QUALIFIER_RE = re.compile(
195
+ r"(?:(?<=[\s,;(\[\"'])|^)(?:" + _QUALIFIER_KEYS_ALT + r"):(?:[<>]=?)?(?:\"[^\"]*\"|[^\s,;()\[\]]+)[,;]?",
196
+ re.IGNORECASE,
197
+ )
198
+
199
+ # A qualifier fully wrapped in a single pair of parens/brackets/quotes. The
200
+ # wrapper pair is consumed with the qualifier, so stripping leaves no residue.
201
+ # An unbalanced quote (e.g. `label:"bug`) is not a wrapper shape and is left
202
+ # to _QUALIFIER_RE, which strips the qualifier as before.
203
+ _WRAPPED_QUALIFIER_RE = re.compile(
204
+ r"[\(\[\"'](?:" + _QUALIFIER_KEYS_ALT + r"):(?:[<>]=?)?(?:\"[^\"]*\"|[^\s,;()\[\]]+)[\)\]\"']",
205
+ re.IGNORECASE,
206
+ )
207
+
208
+ # Empty wrapper pairs left behind when a nested wrapper collapses (e.g. the
209
+ # `()` from "((created:>2025-03-20))"). Removed to fixpoint; a pair enclosing
210
+ # real text stays.
211
+ _EMPTY_WRAPPER_RE = re.compile(r"\(\s*\)|\[\s*\]|\"\s*\"|'\s*'")
212
+
213
+
214
+ def strip_search_qualifiers(text: str) -> str:
215
+ """Strip GitHub search qualifiers from a topic, leaving plain-language text.
216
+
217
+ Whitespace is collapsed. Returns an empty string when the topic was nothing
218
+ but qualifiers; callers must handle that rather than searching on an empty
219
+ term, which would match the entire site.
220
+ """
221
+ stripped = _WRAPPED_QUALIFIER_RE.sub(" ", text)
222
+ stripped = _QUALIFIER_RE.sub(" ", stripped)
223
+ while True:
224
+ cleaned = _EMPTY_WRAPPER_RE.sub("", stripped)
225
+ if cleaned == stripped:
226
+ break
227
+ stripped = cleaned
228
+ return " ".join(stripped.split())
229
+
230
+
231
+ # Bound topic fragments in logs/error envelopes so fanout cannot blow them up (#954).
232
+ _DIAGNOSTIC_TOPIC_LIMIT = 120
233
+
234
+
235
+ def _truncate_diagnostic(text: str, limit: int = _DIAGNOSTIC_TOPIC_LIMIT) -> str:
236
+ """Cap a topic fragment used in logs or error envelopes."""
237
+ if len(text) <= limit:
238
+ return text
239
+ return f"{text[:limit]}..."
240
+
241
+
154
242
  def search_github(
155
243
  topic: str,
156
244
  from_date: str,
@@ -178,6 +266,26 @@ def search_github(
178
266
  """
179
267
  count = DEPTH_LIMITS.get(depth, DEPTH_LIMITS["default"])
180
268
  core = extract_core_subject(topic)
269
+ plain_core = strip_search_qualifiers(core)
270
+ if not plain_core:
271
+ # A qualifier-only (or empty) topic leaves nothing to search on.
272
+ # Skip the network rather than querying an empty term, which would
273
+ # match the whole site (#949). Return a clean empty envelope, not an
274
+ # error, so the pipeline records NO_RESULTS instead of ERROR (#953)
275
+ # and GitHub is not marked as a failed attempt. Mixed-topic strip
276
+ # logs below stay bounded by _truncate_diagnostic (#954).
277
+ _log("Topic contained only search qualifiers or was empty; nothing to search")
278
+ return {
279
+ "items": [],
280
+ "context": {"core": core, "from_date": from_date,
281
+ "to_date": to_date, "count": count},
282
+ }
283
+ if plain_core != core:
284
+ _log(
285
+ "Stripped search qualifiers: "
286
+ f"'{_truncate_diagnostic(core)}' -> '{_truncate_diagnostic(plain_core)}'"
287
+ )
288
+ core = plain_core
181
289
  resolved_token = _resolve_token(token)
182
290
  authed = bool(resolved_token)
183
291
  if not authed:
@@ -188,20 +296,70 @@ def search_github(
188
296
  _log(f"Searching for '{core}' (raw: '{topic}', since {from_date}, count={count})")
189
297
 
190
298
  # Build search query with date filter
191
- q = f"{core} created:>{from_date}"
192
- params = {
193
- "q": q,
194
- "sort": "reactions",
195
- "order": "desc",
196
- "per_page": str(min(count, 100)),
197
- }
198
- url = f"{SEARCH_URL}?{urllib.parse.urlencode(params)}"
199
-
200
- data = _fetch_json(url, token=resolved_token, timeout=30)
299
+ base_q = f"{core} created:>{from_date}"
300
+
301
+ def _search(qualifier: Optional[str]) -> Optional[Dict[str, Any]]:
302
+ q = f"{base_q} {qualifier}" if qualifier else base_q
303
+ params = {
304
+ "q": q,
305
+ "sort": "reactions",
306
+ "order": "desc",
307
+ "per_page": str(min(count, 100)),
308
+ }
309
+ url = f"{SEARCH_URL}?{urllib.parse.urlencode(params)}"
310
+ return _fetch_json(url, token=resolved_token, timeout=30,
311
+ failure_out=fetch_failures)
312
+
313
+ fetch_failures: List[str] = []
314
+ partition_failure: Optional[str] = None
315
+ if authed:
316
+ # GitHub rejects AUTHENTICATED /search/issues queries that carry
317
+ # neither `is:issue` nor `is:pull-request` (HTTP 422). Anonymous
318
+ # queries are still grandfathered, which is why this only bites once
319
+ # a token is present -- including via the `gh auth token` fallback.
320
+ #
321
+ # Appending a single qualifier would silently halve the corpus: for
322
+ # "rust async created:>2026-08-02" GitHub reports 1,072 issues and
323
+ # 7,044 pull requests against 8,115 combined, so `is:issue` alone
324
+ # drops ~87% of matches. Query both and merge instead, which keeps
325
+ # coverage AND the authenticated rate limit.
326
+ merged: List[Dict[str, Any]] = []
327
+ seen_ids = set()
328
+ failed_qualifiers: List[str] = []
329
+ for qualifier in ("is:issue", "is:pull-request"):
330
+ part = _search(qualifier)
331
+ if part is None:
332
+ failed_qualifiers.append(qualifier)
333
+ continue
334
+ for item in part.get("items", []):
335
+ item_id = item.get("id")
336
+ if item_id in seen_ids:
337
+ continue
338
+ seen_ids.add(item_id)
339
+ merged.append(item)
340
+ # Both sub-queries are reaction-sorted; the merge is not, so re-sort
341
+ # before truncating or the second query's tail would outrank the
342
+ # first query's head.
343
+ merged.sort(key=lambda i: (i.get("reactions") or {}).get("total_count", 0),
344
+ reverse=True)
345
+ data = {"items": merged[:count]} if merged else None
346
+ if failed_qualifiers:
347
+ partition_failure = (
348
+ f"GitHub partition(s) failed: {', '.join(failed_qualifiers)}"
349
+ + (f" ({fetch_failures[-1]})" if fetch_failures else "")
350
+ )
351
+ else:
352
+ data = _search(None)
201
353
  if not data:
202
354
  envelope = {"items": [], "context": {"core": core, "from_date": from_date,
203
355
  "to_date": to_date, "count": count}}
204
- if not authed:
356
+ if authed and partition_failure:
357
+ envelope["error"] = partition_failure
358
+ elif authed and fetch_failures:
359
+ # Authenticated transport failures must not be laundered into a
360
+ # clean no-results outcome (issue #384).
361
+ envelope["error"] = f"GitHub API request failed: {fetch_failures[-1]}"
362
+ elif not authed:
205
363
  # Could be the anon rate limit (403) or an unprocessable query (422)
206
364
  # -- _fetch_json maps both to None. Don't over-claim which; suggest a
207
365
  # token since that fixes the common (rate-limit) case.
@@ -214,7 +372,7 @@ def search_github(
214
372
  raw_items = data.get("items", [])
215
373
  _log(f"Found {len(raw_items)} issues/PRs")
216
374
 
217
- return {
375
+ envelope: Dict[str, Any] = {
218
376
  "items": raw_items,
219
377
  "context": {
220
378
  "core": core,
@@ -223,6 +381,9 @@ def search_github(
223
381
  "count": count,
224
382
  },
225
383
  }
384
+ if partition_failure:
385
+ envelope["error"] = partition_failure
386
+ return envelope
226
387
 
227
388
 
228
389
  def parse_github_response(response: Dict[str, Any]) -> List[Dict[str, Any]]:
@@ -249,7 +410,7 @@ def parse_github_response(response: Dict[str, Any]) -> List[Dict[str, Any]]:
249
410
  title = item.get("title", "")
250
411
  body_text = item.get("body") or ""
251
412
  reactions_total = item.get("reactions", {}).get("total_count", 0) if isinstance(item.get("reactions"), dict) else 0
252
- comment_count = item.get("comments", 0)
413
+ comment_count = item.get("comments") or 0
253
414
  labels = [
254
415
  lbl.get("name", "") for lbl in (item.get("labels") or [])
255
416
  if isinstance(lbl, dict)
@@ -407,6 +568,8 @@ PERSON_DEPTH_LIMITS = {
407
568
  "deep": {"pr_pages": 2, "own_repos": 5, "external_repos": 15},
408
569
  }
409
570
 
571
+ PERSON_EVENTS_PER_PAGE = 100
572
+
410
573
 
411
574
  def _fetch_readme_snippet(repo: str, token: str, max_chars: int = 500) -> Optional[str]:
412
575
  """Fetch README content for a repo, truncated to first ~max_chars."""
@@ -420,7 +583,7 @@ def _fetch_readme_snippet(repo: str, token: str, max_chars: int = 500) -> Option
420
583
 
421
584
  req = urllib.request.Request(url, headers=headers)
422
585
  try:
423
- with urllib.request.urlopen(req, timeout=10) as resp:
586
+ with http.open_request(req, 10) as resp:
424
587
  raw = resp.read().decode("utf-8", errors="replace")
425
588
  except (urllib.error.HTTPError, urllib.error.URLError, OSError, TimeoutError):
426
589
  return None
@@ -468,7 +631,7 @@ def _fetch_top_issues(repo: str, token: str) -> Dict[str, Any]:
468
631
  result["top_feature_request"] = {
469
632
  "title": item.get("title", ""),
470
633
  "reactions": item.get("reactions", {}).get("total_count", 0) if isinstance(item.get("reactions"), dict) else 0,
471
- "comments": item.get("comments", 0),
634
+ "comments": item.get("comments") or 0,
472
635
  "url": item.get("html_url", ""),
473
636
  }
474
637
  elif feat_data and feat_data.get("total_count", 0) == 0:
@@ -481,7 +644,7 @@ def _fetch_top_issues(repo: str, token: str) -> Dict[str, Any]:
481
644
  result["top_feature_request"] = {
482
645
  "title": item.get("title", ""),
483
646
  "reactions": item.get("reactions", {}).get("total_count", 0) if isinstance(item.get("reactions"), dict) else 0,
484
- "comments": item.get("comments", 0),
647
+ "comments": item.get("comments") or 0,
485
648
  "url": item.get("html_url", ""),
486
649
  }
487
650
 
@@ -494,7 +657,7 @@ def _fetch_top_issues(repo: str, token: str) -> Dict[str, Any]:
494
657
  result["top_complaint"] = {
495
658
  "title": item.get("title", ""),
496
659
  "reactions": item.get("reactions", {}).get("total_count", 0) if isinstance(item.get("reactions"), dict) else 0,
497
- "comments": item.get("comments", 0),
660
+ "comments": item.get("comments") or 0,
498
661
  "url": item.get("html_url", ""),
499
662
  }
500
663
 
@@ -525,6 +688,45 @@ def _format_stars(n: int) -> str:
525
688
  return str(n)
526
689
 
527
690
 
691
+ def refetch_datum(item: schema.SourceItem | None, datum_key: str) -> dict[str, Any]:
692
+ """Re-fetch one repository counter through the shared HTTP wrapper.
693
+
694
+ ``datum_key`` is either the literal ``"stars"`` (item-level claim; the
695
+ repo derives from the grounding item) or an ``owner/repo`` slug
696
+ (candidate-enrichment claim; the repo itself is the refetch subject and
697
+ the item is not consulted, so it may be ``None``).
698
+ """
699
+ if re.fullmatch(r"[^/\s]+/[^/\s]+", datum_key):
700
+ repo = datum_key
701
+ elif datum_key != "stars":
702
+ raise KeyError(f"Unsupported GitHub datum: {datum_key}")
703
+ else:
704
+ if item is None:
705
+ raise ValueError("Item-level star refetch requires the grounding item")
706
+ repo = item.container or ""
707
+ if not re.fullmatch(r"[^/\s]+/[^/\s]+", repo):
708
+ match = re.match(r"https?://github\.com/([^/]+/[^/#?]+)", item.url)
709
+ repo = match.group(1).removesuffix(".git") if match else ""
710
+ if not repo:
711
+ raise ValueError("GitHub item has no owner/repository reference")
712
+ headers = {"Accept": "application/vnd.github+json"}
713
+ token = _resolve_token()
714
+ if token:
715
+ headers["Authorization"] = f"Bearer {token}"
716
+ data = http.request(
717
+ "GET", f"https://api.github.com/repos/{repo}",
718
+ headers=headers, timeout=10, retries=2,
719
+ )
720
+ if not isinstance(data, dict) or not isinstance(data.get("stargazers_count"), int):
721
+ raise KeyError("GitHub star count was not returned")
722
+ fallback_url = item.url if item is not None else f"https://github.com/{repo}"
723
+ return {
724
+ "value": data["stargazers_count"],
725
+ "url": str(data.get("html_url") or fallback_url),
726
+ "timestamp": data.get("updated_at"),
727
+ }
728
+
729
+
528
730
  def search_github_person(
529
731
  username: str,
530
732
  from_date: str,
@@ -564,6 +766,17 @@ def search_github_person(
564
766
  _log(f"Found {total_prs} total PRs, {merged_count} merged")
565
767
 
566
768
  if total_prs == 0 and merged_count == 0:
769
+ # An empty PR search can mean no PRs in the window or an account that
770
+ # GitHub's issue index cannot search. Public PushEvents provide an
771
+ # actor-attributed fallback for either case.
772
+ search_unavailable = total_data is None or merged_data is None
773
+ recent = _person_recent_pushes(
774
+ username, from_date, to_date, limits, resolved_token,
775
+ )
776
+ if recent:
777
+ reason = "account not searchable" if search_unavailable else "no PRs in window"
778
+ _log(f"PR search empty ({reason}); public events returned {len(recent)} items")
779
+ return recent
567
780
  _log("No PRs found, falling back to keyword search")
568
781
  return []
569
782
 
@@ -681,7 +894,7 @@ def search_github_person(
681
894
  "id": f"GH{idx}",
682
895
  "title": f"{repo} ({stars_str} stars) - {pr_count} PRs merged",
683
896
  "url": f"https://github.com/{repo}",
684
- "date": releases[0]["date"] if releases and releases[0].get("date") else to_date,
897
+ "date": _snapshot_date(releases, from_date, to_date),
685
898
  "author": username,
686
899
  "source": "github",
687
900
  "score": stars,
@@ -696,6 +909,7 @@ def search_github_person(
696
909
  "comment_count": pr_count,
697
910
  "reactions": stars,
698
911
  "is_pr": False,
912
+ "date_is_snapshot": not _has_in_window_release(releases, from_date, to_date),
699
913
  },
700
914
  })
701
915
 
@@ -739,7 +953,7 @@ def search_github_person(
739
953
  "id": f"GH{idx}",
740
954
  "title": f"{repo_name} ({stars_str} stars) - own project, {open_issues} open issues",
741
955
  "url": f"https://github.com/{repo_name}",
742
- "date": releases[0]["date"] if releases and releases[0].get("date") else to_date,
956
+ "date": _snapshot_date(releases, from_date, to_date),
743
957
  "author": username,
744
958
  "source": "github",
745
959
  "score": stars,
@@ -754,6 +968,7 @@ def search_github_person(
754
968
  "comment_count": open_issues,
755
969
  "reactions": stars,
756
970
  "is_pr": False,
971
+ "date_is_snapshot": not _has_in_window_release(releases, from_date, to_date),
757
972
  },
758
973
  })
759
974
 
@@ -763,6 +978,167 @@ def search_github_person(
763
978
  return items
764
979
 
765
980
 
981
+ def _person_recent_pushes(
982
+ username: str,
983
+ from_date: str,
984
+ to_date: str,
985
+ limits: Dict[str, int],
986
+ token: str,
987
+ ) -> List[Dict[str, Any]]:
988
+ """Return repos the selected actor publicly pushed inside the window."""
989
+ latest_by_repo: Dict[str, Dict[str, str]] = {}
990
+ encoded_username = urllib.parse.quote(username, safe="")
991
+
992
+ page = 1
993
+ while True:
994
+ url = (
995
+ f"https://api.github.com/users/{encoded_username}/events/public"
996
+ f"?per_page={PERSON_EVENTS_PER_PAGE}&page={page}"
997
+ )
998
+ data = _fetch_json(url, token=token, timeout=15)
999
+ if not data or not isinstance(data, list):
1000
+ break
1001
+
1002
+ reached_before_window = False
1003
+ for event in data:
1004
+ created_at = event.get("created_at")
1005
+ pushed = _parse_date(created_at)
1006
+ if not pushed:
1007
+ continue
1008
+ if pushed < from_date:
1009
+ reached_before_window = True
1010
+ break
1011
+ if pushed > to_date or event.get("type") != "PushEvent":
1012
+ continue
1013
+
1014
+ actor = event.get("actor")
1015
+ actor_login = actor.get("login", "") if isinstance(actor, dict) else ""
1016
+ if actor_login.casefold() != username.casefold():
1017
+ continue
1018
+
1019
+ repo = event.get("repo")
1020
+ full_name = repo.get("name", "") if isinstance(repo, dict) else ""
1021
+ if not re.fullmatch(r"[^/\s]+/[^/\s]+", full_name):
1022
+ continue
1023
+
1024
+ previous = latest_by_repo.get(full_name)
1025
+ if previous is None or created_at > previous["created_at"]:
1026
+ latest_by_repo[full_name] = {
1027
+ "full_name": full_name,
1028
+ "pushed": pushed,
1029
+ "created_at": created_at,
1030
+ "actor": actor_login,
1031
+ "event_id": str(event.get("id") or ""),
1032
+ }
1033
+
1034
+ if reached_before_window or len(data) < PERSON_EVENTS_PER_PAGE:
1035
+ break
1036
+ page += 1
1037
+
1038
+ if not latest_by_repo:
1039
+ return []
1040
+
1041
+ recent = sorted(
1042
+ latest_by_repo.values(),
1043
+ key=lambda r: r["created_at"],
1044
+ reverse=True,
1045
+ )
1046
+ _log(
1047
+ f"Public events: {len(recent)} actor-attributed repos pushed in window, "
1048
+ "loading repository metadata for ranking"
1049
+ )
1050
+
1051
+ repo_info: Dict[str, Dict[str, Any]] = {}
1052
+ with ThreadPoolExecutor(max_workers=8) as executor:
1053
+ info_futures = {
1054
+ executor.submit(_fetch_repo_info, r["full_name"], token): r["full_name"]
1055
+ for r in recent
1056
+ }
1057
+ for future in as_completed(info_futures):
1058
+ name = info_futures[future]
1059
+ try:
1060
+ repo_info[name] = future.result(timeout=20) or {}
1061
+ except Exception as exc:
1062
+ _log(f"Push-event repo metadata failed for {name}: {exc}")
1063
+ repo_info[name] = {}
1064
+
1065
+ recent.sort(
1066
+ key=lambda r: (
1067
+ repo_info.get(r["full_name"], {}).get("stars", 0),
1068
+ r["created_at"],
1069
+ ),
1070
+ reverse=True,
1071
+ )
1072
+ selected = recent[:limits["own_repos"]]
1073
+
1074
+ enrichments: Dict[str, Dict[str, Any]] = {}
1075
+ _log(f"Public events: enriching {len(selected)} top-ranked repositories")
1076
+ with ThreadPoolExecutor(max_workers=8) as executor:
1077
+ enrichment_futures = {
1078
+ executor.submit(_enrich_own_repo, r["full_name"], token): r["full_name"]
1079
+ for r in selected
1080
+ }
1081
+ for future in as_completed(enrichment_futures):
1082
+ name = enrichment_futures[future]
1083
+ try:
1084
+ enrichments[name] = future.result(timeout=25)
1085
+ except Exception as exc:
1086
+ _log(f"Push-event enrichment failed for {name}: {exc}")
1087
+ enrichments[name] = {}
1088
+
1089
+ items: List[Dict[str, Any]] = []
1090
+ for idx, repo in enumerate(selected, start=1):
1091
+ name = repo["full_name"]
1092
+ info = repo_info.get(name, {})
1093
+ stars = info.get("stars", 0)
1094
+ stars_str = _format_stars(stars)
1095
+ open_issues = info.get("open_issues", 0)
1096
+ enrichment = enrichments.get(name, {})
1097
+ readme = enrichment.get("readme")
1098
+ releases = enrichment.get("releases", [])
1099
+
1100
+ snippet_parts = [
1101
+ f"@{repo['actor']} pushed {name} on {repo['pushed']} "
1102
+ f"({stars_str} stars, {open_issues} open issues)"
1103
+ ]
1104
+ if info.get("description"):
1105
+ snippet_parts.append(f" {info['description']}")
1106
+ if readme:
1107
+ snippet_parts.append(f" README: {readme[:300]}")
1108
+ for rel in releases[:2]:
1109
+ body_preview = f" - {rel['body'][:150]}" if rel.get("body") else ""
1110
+ snippet_parts.append(f" Release: {rel['name']} ({rel['date']}){body_preview}")
1111
+
1112
+ items.append({
1113
+ "id": f"GH{idx}",
1114
+ "title": f"@{repo['actor']} pushed {name} on {repo['pushed']}",
1115
+ "url": f"https://github.com/{name}",
1116
+ "date": repo["pushed"],
1117
+ "author": repo["actor"],
1118
+ "source": "github",
1119
+ "score": stars,
1120
+ "container": name,
1121
+ "snippet": "\n".join(snippet_parts),
1122
+ "relevance": min(0.9, 0.6 + math.log1p(stars) / 30),
1123
+ "why_relevant": (
1124
+ f"GitHub activity: @{repo['actor']} pushed {name} on {repo['pushed']} "
1125
+ f"({stars_str} stars)"
1126
+ ),
1127
+ "engagement": {"stars": stars, "comments": open_issues},
1128
+ "metadata": {
1129
+ "labels": ["person-profile", "recent-push"],
1130
+ "state": "open",
1131
+ "comment_count": open_issues,
1132
+ "reactions": stars,
1133
+ "is_pr": False,
1134
+ "event_type": "PushEvent",
1135
+ "event_id": repo["event_id"],
1136
+ },
1137
+ })
1138
+
1139
+ return items
1140
+
1141
+
766
1142
  def _enrich_external_repo(repo: str, token: str) -> Dict[str, Any]:
767
1143
  """Fetch star count + releases for an external repo."""
768
1144
  info = _fetch_repo_info(repo, token)
@@ -782,6 +1158,29 @@ def _enrich_own_repo(repo: str, token: str) -> Dict[str, Any]:
782
1158
  # Project-mode search: fetch comprehensive data for specific repos
783
1159
  # ---------------------------------------------------------------------------
784
1160
 
1161
+ def _snapshot_date(releases: List[Dict[str, Any]], from_date: str, to_date: str) -> str:
1162
+ """Date for a live repo snapshot item (stars, issues, PRs read now).
1163
+
1164
+ The newest release's date when it falls in the window, else ``to_date``.
1165
+ An older release date would make the report's date filter drop a repo the
1166
+ user pinned: Ghostty's newest release object is its 2022 "tip" nightly.
1167
+ """
1168
+ if _has_in_window_release(releases, from_date, to_date):
1169
+ return releases[0]["date"]
1170
+ return to_date
1171
+
1172
+
1173
+ def _has_in_window_release(releases: List[Dict[str, Any]], from_date: str, to_date: str) -> bool:
1174
+ """True when the newest release date is a real in-window event.
1175
+
1176
+ False means ``_snapshot_date`` fell back to ``to_date``: that date keeps the
1177
+ item through the date filter but says nothing about recency, so the item is
1178
+ marked ``date_is_snapshot`` and ranks on neutral freshness.
1179
+ """
1180
+ release_date = releases[0].get("date") if releases else None
1181
+ return bool(release_date and from_date <= release_date <= to_date)
1182
+
1183
+
785
1184
  def search_github_project(
786
1185
  repos: List[str],
787
1186
  from_date: str,
@@ -859,7 +1258,7 @@ def search_github_project(
859
1258
  "id": f"GH{idx + 1}",
860
1259
  "title": f"{repo} ({stars_str} stars) - {open_issues} open issues",
861
1260
  "url": f"https://github.com/{repo}",
862
- "date": releases[0]["date"] if releases and releases[0].get("date") else to_date,
1261
+ "date": _snapshot_date(releases, from_date, to_date),
863
1262
  "author": repo.split("/")[0],
864
1263
  "source": "github",
865
1264
  "score": stars,
@@ -874,6 +1273,7 @@ def search_github_project(
874
1273
  "comment_count": open_issues,
875
1274
  "reactions": stars,
876
1275
  "is_pr": False,
1276
+ "date_is_snapshot": not _has_in_window_release(releases, from_date, to_date),
877
1277
  "github_stars": {repo: stars},
878
1278
  },
879
1279
  })
@@ -931,6 +1331,7 @@ def enrich_candidates_with_stars(
931
1331
  token: Optional[str] = None,
932
1332
  already_enriched: Optional[set] = None,
933
1333
  max_repos: int = 10,
1334
+ collect_map: Optional[Dict[str, int]] = None,
934
1335
  ) -> int:
935
1336
  """Annotate candidates with live GitHub star counts.
936
1337
 
@@ -964,9 +1365,22 @@ def enrich_candidates_with_stars(
964
1365
  except Exception:
965
1366
  pass
966
1367
 
1368
+ if collect_map is not None:
1369
+ collect_map.update(star_map)
967
1370
  if not star_map:
968
1371
  return 0
969
1372
 
1373
+ return apply_star_map(candidates, star_map)
1374
+
1375
+
1376
+ def apply_star_map(candidates: List[Any], star_map: Dict[str, int]) -> int:
1377
+ """Annotate candidates from a repo->stars map (fetch/apply split).
1378
+
1379
+ Split out so offline replay (the eval harness) can apply a recorded map
1380
+ without any network or gh-credential access.
1381
+ """
1382
+ if not star_map:
1383
+ return 0
970
1384
  # Annotate candidates
971
1385
  enriched_count = 0
972
1386
  for c in candidates: