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
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env python3
2
+ # fmt: off
2
3
  # ruff: noqa: E402
3
4
  """last30days CLI."""
4
5
 
@@ -7,12 +8,14 @@ from __future__ import annotations
7
8
  import argparse
8
9
  import atexit
9
10
  import datetime
11
+ import hashlib
10
12
  import json
11
13
  import os
12
14
  import re
13
15
  import signal
16
+ import sqlite3
14
17
  import sys
15
- import threading
18
+ from collections.abc import Callable
16
19
  from pathlib import Path
17
20
 
18
21
  MIN_PYTHON = (3, 12)
@@ -47,36 +50,51 @@ if os.name == "nt":
47
50
  SCRIPT_DIR = Path(__file__).parent.resolve()
48
51
  sys.path.insert(0, str(SCRIPT_DIR))
49
52
 
50
- from lib import dates, env, html_render, permission_preflight, pipeline, render, schema, ui
53
+ from lib import competitors as competitors_mod, corpus, dates, discovery_handoff, env, fetch_memo, freshness, html_render, http, permission_preflight, pipeline, reddit, registers, render, schema, subproc, ui, x_envelope
54
+ from lib.private_files import write_private_text
51
55
 
52
- _child_pids: set[int] = set()
53
- _child_pids_lock = threading.Lock()
56
+ atexit.register(subproc.cleanup_children)
54
57
 
55
58
 
56
- def register_child_pid(pid: int) -> None:
57
- with _child_pids_lock:
58
- _child_pids.add(pid)
59
+ def _on_sigterm(signum, frame) -> None:
60
+ """SIGTERM handler: clean descendant groups, then die as SIGTERM.
59
61
 
62
+ Every run_with_timeout child runs in its own pgid (lib/subproc.py via
63
+ os.setsid), so a group kill aimed at the engine can never reach them;
64
+ only the lib.subproc registry can. atexit never runs on a signal death,
65
+ so without this handler an MCP timeout would orphan node bird-search,
66
+ yt-dlp, and the digg CLI. The MCP server SIGTERMs the engine group
67
+ first, giving this handler room to killpg() each registered child
68
+ group before the SIGKILL backstop. Restoring the default disposition
69
+ and re-raising preserves killed-by-SIGTERM semantics for the parent.
70
+ """
71
+ subproc.cleanup_children()
72
+ signal.signal(signal.SIGTERM, signal.SIG_DFL)
73
+ os.kill(os.getpid(), signal.SIGTERM)
60
74
 
61
- def unregister_child_pid(pid: int) -> None:
62
- with _child_pids_lock:
63
- _child_pids.discard(pid)
64
75
 
76
+ def _install_sigterm_handler() -> None:
77
+ try:
78
+ signal.signal(signal.SIGTERM, _on_sigterm)
79
+ except (ValueError, OSError, RuntimeError):
80
+ pass
65
81
 
66
- def _cleanup_children() -> None:
67
- with _child_pids_lock:
68
- pids = list(_child_pids)
69
- for pid in pids:
70
- try:
71
- if hasattr(os, "killpg"):
72
- os.killpg(os.getpgid(pid), signal.SIGTERM)
73
- else:
74
- os.kill(pid, signal.SIGTERM)
75
- except (ProcessLookupError, PermissionError, OSError):
76
- continue
77
82
 
83
+ def parse_meta_ads_page(raw: str) -> str:
84
+ """Extract an Ad Library page id from a flag value, or "" if there is none.
78
85
 
79
- atexit.register(_cleanup_children)
86
+ Accepts a bare numeric id or any Ad Library URL carrying
87
+ ``view_all_page_id``. A ``facebook.com/<vanity>`` URL is deliberately
88
+ rejected rather than guessed at: a vanity handle is not a page id, and one
89
+ live check resolved a brand-looking handle to a private person's profile.
90
+ """
91
+ value = str(raw or "").strip()
92
+ if not value:
93
+ return ""
94
+ if re.fullmatch(r"\d{5,20}", value):
95
+ return value
96
+ match = re.search(r"view_all_page_id=(\d{5,20})", value)
97
+ return match.group(1) if match else ""
80
98
 
81
99
 
82
100
  def parse_search_flag(raw: str, flag_name: str = "--search") -> list[str]:
@@ -115,11 +133,192 @@ def resolve_requested_sources(args_search: str | None, config: dict) -> list[str
115
133
  return None
116
134
 
117
135
 
118
- def slugify(value: str) -> str:
136
+ def add_deep_research_source(
137
+ requested_sources: list[str] | None,
138
+ ) -> list[str] | None:
139
+ """Add Perplexity without replacing the default-source sentinel.
140
+
141
+ ``None`` means that the planner can use the normal configured source set.
142
+ Deep Research enables Perplexity through ``INCLUDE_SOURCES`` separately, so
143
+ converting this sentinel to ``["perplexity"]`` would suppress every normal
144
+ source.
145
+ """
146
+ if requested_sources is None:
147
+ return None
148
+ if "perplexity" in requested_sources:
149
+ return requested_sources
150
+ return [*requested_sources, "perplexity"]
151
+
152
+
153
+ def enable_deep_research_source(config: dict) -> None:
154
+ """Enable the exact Perplexity token or reject a hard exclusion."""
155
+ excluded = {
156
+ token.strip().lower()
157
+ for token in str(config.get("EXCLUDE_SOURCES") or "").split(",")
158
+ if token.strip()
159
+ }
160
+ if "perplexity" in excluded:
161
+ raise ValueError(
162
+ "--deep-research conflicts with EXCLUDE_SOURCES=perplexity"
163
+ )
164
+
165
+ include = str(config.get("INCLUDE_SOURCES") or "")
166
+ tokens = [token.strip() for token in include.split(",") if token.strip()]
167
+ if "perplexity" not in {token.lower() for token in tokens}:
168
+ tokens.append("perplexity")
169
+ config["INCLUDE_SOURCES"] = ",".join(tokens)
170
+
171
+
172
+ def plan_has_explicit_trustpilot_domain(comp_plan: dict | None) -> bool:
173
+ """True when any --competitors-plan entry pins a trustpilot_domain."""
174
+ if not comp_plan:
175
+ return False
176
+ for entry in comp_plan.values():
177
+ if not isinstance(entry, dict):
178
+ continue
179
+ domain = entry.get("trustpilot_domain")
180
+ if isinstance(domain, str) and domain.strip():
181
+ return True
182
+ return False
183
+
184
+
185
+ def activate_trustpilot_for_explicit_domain(
186
+ config: dict,
187
+ requested_sources: list[str] | None,
188
+ *,
189
+ reason: str,
190
+ ) -> list[str] | None:
191
+ """Activate the opt-in Trustpilot source when the user pinned a domain.
192
+
193
+ Passing ``--trustpilot-domain`` (or a plan-level ``trustpilot_domain``) is
194
+ unambiguous intent — silently ignoring it when Trustpilot is not in
195
+ ``INCLUDE_SOURCES`` / ``--search`` is the #873 failure mode. Auto-resolve
196
+ hints must not call this helper.
197
+
198
+ ``EXCLUDE_SOURCES=trustpilot`` still wins. Mutates ``config`` in place and
199
+ returns the (possibly extended) ``requested_sources`` list.
200
+ """
201
+ excluded = {
202
+ token.strip().lower()
203
+ for token in str(config.get("EXCLUDE_SOURCES") or "").split(",")
204
+ if token.strip()
205
+ }
206
+ if "trustpilot" in excluded:
207
+ sys.stderr.write(
208
+ f"[Trustpilot] {reason} ignored: trustpilot is in EXCLUDE_SOURCES\n"
209
+ )
210
+ return requested_sources
211
+
212
+ include = str(config.get("INCLUDE_SOURCES") or "")
213
+ tokens = [token.strip() for token in include.split(",") if token.strip()]
214
+ if "trustpilot" not in {token.lower() for token in tokens}:
215
+ tokens.append("trustpilot")
216
+ config["INCLUDE_SOURCES"] = ",".join(tokens)
217
+ sys.stderr.write(
218
+ f"[Trustpilot] {reason} activated trustpilot source "
219
+ "(add to INCLUDE_SOURCES permanently to skip this auto-enable)\n"
220
+ )
221
+
222
+ if requested_sources is not None and "trustpilot" not in requested_sources:
223
+ requested_sources = [*requested_sources, "trustpilot"]
224
+ return requested_sources
225
+
226
+
227
+ def activate_telegram_for_explicit_sources(
228
+ config: dict,
229
+ requested_sources: list[str] | None,
230
+ *,
231
+ channels: str,
232
+ ) -> list[str] | None:
233
+ """Activate the opt-in Telegram source when the user pinned channel(s).
234
+
235
+ Passing ``--telegram-sources`` is unambiguous intent — silently ignoring it
236
+ when Telegram is not in ``INCLUDE_SOURCES`` / ``--search`` is the same
237
+ failure mode as #873 (Trustpilot). Auto-activate the source.
238
+
239
+ ``EXCLUDE_SOURCES=telegram`` still wins. Mutates ``config`` in place and
240
+ returns the (possibly extended) ``requested_sources`` list.
241
+ """
242
+ excluded = {
243
+ token.strip().lower()
244
+ for token in str(config.get("EXCLUDE_SOURCES") or "").split(",")
245
+ if token.strip()
246
+ }
247
+ if "telegram" in excluded:
248
+ sys.stderr.write(
249
+ f"[Telegram] --telegram-sources={channels} ignored: telegram is in EXCLUDE_SOURCES\n"
250
+ )
251
+ return requested_sources
252
+
253
+ config["TELEGRAM_SOURCES"] = channels
254
+
255
+ include = str(config.get("INCLUDE_SOURCES") or "")
256
+ tokens = [token.strip() for token in include.split(",") if token.strip()]
257
+ if "telegram" not in {token.lower() for token in tokens}:
258
+ tokens.append("telegram")
259
+ config["INCLUDE_SOURCES"] = ",".join(tokens)
260
+ sys.stderr.write(
261
+ f"[Telegram] --telegram-sources={channels} activated telegram source "
262
+ "(add to INCLUDE_SOURCES permanently to skip this auto-enable)\n"
263
+ )
264
+
265
+ if requested_sources is not None and "telegram" not in requested_sources:
266
+ requested_sources = [*requested_sources, "telegram"]
267
+ return requested_sources
268
+
269
+
270
+ def slugify(value: str, max_length: int = 180) -> str:
119
271
  slug = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
272
+ if len(slug) > max_length:
273
+ # Filenames built from long topics can exceed the OS 255-byte limit
274
+ # (macOS errno 63). Truncate and append a hash of the full value so
275
+ # distinct long topics still get distinct, deterministic names.
276
+ digest = hashlib.sha1(slug.encode("utf-8")).hexdigest()[:10]
277
+ slug = f"{slug[:max_length].rstrip('-')}-{digest}"
120
278
  return slug or "last30days"
121
279
 
122
280
 
281
+ def sanitize_suffix(suffix: str) -> str:
282
+ """Sanitize a user-provided ``--save-suffix`` into a path-safe token.
283
+
284
+ The suffix is glued directly into the saved-report filename, so restrict it
285
+ to the same ``[a-z0-9-]`` class as the topic slug. This neutralizes path
286
+ separators and parent refs (``/``, ``..``) so a suffix can never escape the
287
+ save directory, while leaving ordinary values ('v3', 'gemini', a client
288
+ slug) unchanged. Unlike ``slugify`` there is no fallback token: a suffix
289
+ that sanitizes to nothing simply drops, yielding no suffix part.
290
+ """
291
+ return re.sub(r"[^a-z0-9]+", "-", suffix.lower()).strip("-")
292
+
293
+
294
+ def _report_has_private_corpus(report: schema.Report) -> bool:
295
+ items_by_source = getattr(report, "items_by_source", {})
296
+ if isinstance(items_by_source, dict) and items_by_source.get("corpus"):
297
+ return True
298
+ candidates = getattr(report, "ranked_candidates", ())
299
+ if not isinstance(candidates, (list, tuple)):
300
+ return False
301
+ return any(
302
+ candidate.source == "corpus"
303
+ or any(item.source == "corpus" for item in candidate.source_items)
304
+ for candidate in candidates
305
+ )
306
+
307
+
308
+ def _ensure_output_directory(path: Path, *, private: bool) -> None:
309
+ if not private:
310
+ path.mkdir(parents=True, exist_ok=True)
311
+ return
312
+ missing: list[Path] = []
313
+ current = path
314
+ while not current.exists():
315
+ missing.append(current)
316
+ current = current.parent
317
+ path.mkdir(parents=True, exist_ok=True, mode=0o700)
318
+ for directory in missing:
319
+ directory.chmod(0o700)
320
+
321
+
123
322
  def save_output(
124
323
  report: schema.Report,
125
324
  emit: str,
@@ -128,33 +327,111 @@ def save_output(
128
327
  synthesis_md: str | None = None,
129
328
  topic_override: str | None = None,
130
329
  rendered_content: str | None = None,
330
+ json_profile: str = "agent",
331
+ register: str = "default",
332
+ private: bool | None = None,
333
+ render_fn: Callable[[Path], str] | None = None,
131
334
  ) -> Path:
132
335
  from datetime import datetime
133
336
  path = Path(save_dir).expanduser().resolve()
134
- path.mkdir(parents=True, exist_ok=True)
135
337
  slug = slugify(topic_override or report.topic)
136
338
  extension = "json" if emit == "json" else "html" if emit == "html" else "md"
137
339
  raw_label = "raw-html" if emit == "html" else "raw"
138
- suffix_part = f"-{suffix}" if suffix else ""
139
- out_path = path / f"{slug}-{raw_label}{suffix_part}.{extension}"
140
- if out_path.exists():
141
- out_path = path / f"{slug}-{raw_label}{suffix_part}-{datetime.now().strftime('%Y-%m-%d')}.{extension}"
340
+ safe_suffix = sanitize_suffix(suffix)
341
+ suffix_part = f"-{safe_suffix}" if safe_suffix else ""
342
+ base = path / f"{slug}-{raw_label}{suffix_part}.{extension}"
343
+ date_str = datetime.now().strftime('%Y-%m-%d')
344
+ candidates = [base]
345
+ candidates.append(path / f"{slug}-{raw_label}{suffix_part}-{date_str}.{extension}")
346
+ for i in range(1, 100):
347
+ candidates.append(path / f"{slug}-{raw_label}{suffix_part}-{date_str}-{i}.{extension}")
142
348
  # Markdown saves keep the complete debug artifact. JSON and HTML preserve
143
349
  # their requested wire format so file extensions match their content.
144
- if rendered_content is not None:
145
- content = rendered_content
146
- elif emit in {"json", "html"}:
147
- content = emit_output(report, emit, synthesis_md=synthesis_md)
148
- else:
149
- content = render.render_full(report)
150
- out_path.write_text(content, encoding="utf-8")
151
- return out_path
350
+ # When render_fn is supplied, content is produced after O_EXCL allocates
351
+ # the candidate. This lets the footer cite the file actually written
352
+ # without racing a separate filesystem probe.
353
+ if render_fn is None:
354
+ if rendered_content is not None:
355
+ static_content = rendered_content
356
+ elif emit in {"json", "html"}:
357
+ static_content = emit_output(
358
+ report,
359
+ emit,
360
+ synthesis_md=synthesis_md,
361
+ json_profile=json_profile,
362
+ register=register,
363
+ )
364
+ else:
365
+ static_content = render.render_full(report)
366
+ private_corpus = _report_has_private_corpus(report) or bool(private)
367
+ _ensure_output_directory(path, private=private_corpus)
368
+ for candidate in candidates:
369
+ try:
370
+ fd = os.open(
371
+ candidate,
372
+ os.O_CREAT | os.O_EXCL | os.O_WRONLY,
373
+ 0o600 if private_corpus else 0o644,
374
+ )
375
+ except FileExistsError:
376
+ continue
377
+ try:
378
+ with os.fdopen(fd, "wb") as f:
379
+ content = render_fn(candidate) if render_fn is not None else static_content
380
+ f.write(content.encode("utf-8"))
381
+ except BaseException:
382
+ # Deferred rendering happens after the candidate is reserved. Do
383
+ # not leave an empty or partial report if rendering or writing fails.
384
+ try:
385
+ candidate.unlink(missing_ok=True)
386
+ except OSError:
387
+ pass
388
+ raise
389
+ if candidate.suffix.lower() == ".md":
390
+ try:
391
+ from lib import library, library_index
392
+
393
+ save_root = candidate.parent.resolve()
394
+ if save_root == Path(library.DEFAULT_MEMORY_DIR).expanduser().resolve():
395
+ library_index.sync_library(save_root)
396
+ else:
397
+ # A scoped save must sync a per-directory index with the
398
+ # same paths scoped search uses; syncing the shared DB
399
+ # from one scope's scan would prune other scopes' rows.
400
+ library_index.sync_library(
401
+ save_root,
402
+ save_root / "briefings",
403
+ db_path=save_root / ".last30days-library.db",
404
+ )
405
+ except (library_index.LibrarySearchUnavailable, OSError, sqlite3.DatabaseError):
406
+ # Saving research must not depend on the optional local index;
407
+ # `library search` reports a clear capability error on demand.
408
+ pass
409
+ return candidate
410
+ # Fallback: all 101 candidates existed (extremely unlikely).
411
+ raise RuntimeError(
412
+ f"save_output: could not find a unique filename after 101 attempts in {path}"
413
+ )
152
414
 
153
415
 
154
- def save_rendered_output(rendered_content: str, output_file: str) -> Path:
416
+ def save_rendered_output(
417
+ rendered_content: str,
418
+ output_file: str,
419
+ *,
420
+ private: bool = False,
421
+ ) -> Path:
155
422
  out_path = Path(output_file).expanduser().resolve()
156
- out_path.parent.mkdir(parents=True, exist_ok=True)
157
- out_path.write_text(rendered_content, encoding="utf-8")
423
+ _ensure_output_directory(out_path.parent, private=private)
424
+ if private and out_path.exists():
425
+ out_path.chmod(0o600)
426
+ fd = os.open(
427
+ out_path,
428
+ os.O_CREAT | os.O_TRUNC | os.O_WRONLY,
429
+ 0o600 if private else 0o644,
430
+ )
431
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
432
+ handle.write(rendered_content)
433
+ if private:
434
+ out_path.chmod(0o600)
158
435
  return out_path
159
436
 
160
437
 
@@ -193,8 +470,16 @@ def publish_rendered_html(
193
470
  return result
194
471
 
195
472
 
196
- def _publish_password_for_args(args: argparse.Namespace) -> str | None:
197
- return (args.publish_password or os.environ.get("LAST30DAYS_PUBLISH_PASSWORD") or None)
473
+ def _publish_password_for_args(
474
+ args: argparse.Namespace,
475
+ config: dict[str, object] | None = None,
476
+ ) -> str | None:
477
+ return (
478
+ args.publish_password
479
+ or env.read_secret_env("LAST30DAYS_PUBLISH_PASSWORD")
480
+ or (config or {}).get("LAST30DAYS_PUBLISH_PASSWORD")
481
+ or None
482
+ )
198
483
 
199
484
 
200
485
  def emit_output(
@@ -203,15 +488,31 @@ def emit_output(
203
488
  fun_level: str = "medium",
204
489
  save_path: str | None = None,
205
490
  synthesis_md: str | None = None,
491
+ json_profile: str = "agent",
492
+ register: str = "default",
206
493
  ) -> str:
207
494
  if emit == "json":
208
- return json.dumps(schema.to_dict(report), indent=2, sort_keys=True)
495
+ payload = (
496
+ schema.to_dict(report)
497
+ if json_profile == "raw"
498
+ else schema.to_agent_export(report)
499
+ )
500
+ return json.dumps(payload, indent=2, sort_keys=True)
209
501
  if emit == "html":
210
502
  return html_render.render_html(
211
- report, fun_level=fun_level, save_path=save_path, synthesis_md=synthesis_md,
503
+ report,
504
+ fun_level=fun_level,
505
+ save_path=save_path,
506
+ synthesis_md=synthesis_md,
507
+ register=register,
212
508
  )
213
509
  if emit in {"compact", "md"}:
214
- return render.render_compact(report, fun_level=fun_level, save_path=save_path)
510
+ return render.render_compact(
511
+ report,
512
+ fun_level=fun_level,
513
+ save_path=save_path,
514
+ register=register,
515
+ )
215
516
  if emit == "context":
216
517
  return render.render_context(report)
217
518
  if emit == "brief":
@@ -225,16 +526,26 @@ def emit_comparison_output(
225
526
  fun_level: str = "medium",
226
527
  save_path: str | None = None,
227
528
  synthesis_md: str | None = None,
529
+ json_profile: str = "agent",
228
530
  ) -> str:
229
531
  if emit == "json":
230
532
  payload = {
231
533
  "comparison": True,
232
534
  "entities": [label for label, _ in entity_reports],
233
535
  "reports": [
234
- {"entity": label, "report": schema.to_dict(report)}
536
+ {
537
+ "entity": label,
538
+ "report": (
539
+ schema.to_dict(report)
540
+ if json_profile == "raw"
541
+ else schema.to_agent_export(report)
542
+ ),
543
+ }
235
544
  for label, report in entity_reports
236
545
  ],
237
546
  }
547
+ if json_profile == "agent":
548
+ payload["schema_version"] = schema.AGENT_EXPORT_SCHEMA_VERSION
238
549
  return json.dumps(payload, indent=2, sort_keys=True)
239
550
  if emit == "html":
240
551
  return html_render.render_html_comparison(
@@ -256,6 +567,17 @@ def comparison_topic(entity_reports: list[tuple[str, schema.Report]]) -> str:
256
567
  return " vs ".join(label for label, _ in entity_reports)
257
568
 
258
569
 
570
+ def comparison_label_key(label: str) -> str:
571
+ """Normalize an entity label for duplicate detection.
572
+
573
+ Comparison labels double as keys in the fan-out's results dict, so two
574
+ entities differing only in case, surrounding space, or a repeated space
575
+ collide there while still looking distinct on the command line. Spaces
576
+ are collapsed, never stripped: "Open AI" stays distinct from "OpenAI".
577
+ """
578
+ return " ".join(label.split()).casefold()
579
+
580
+
259
581
  def compute_save_path_display(save_dir: str, topic: str, suffix: str, emit: str) -> str:
260
582
  """Compute the user-friendly save path string that will be shown in the footer.
261
583
 
@@ -267,7 +589,8 @@ def compute_save_path_display(save_dir: str, topic: str, suffix: str, emit: str)
267
589
  slug = slugify(topic)
268
590
  extension = "json" if emit == "json" else "html" if emit == "html" else "md"
269
591
  raw_label = "raw-html" if emit == "html" else "raw"
270
- suffix_part = f"-{suffix}" if suffix else ""
592
+ safe_suffix = sanitize_suffix(suffix)
593
+ suffix_part = f"-{safe_suffix}" if safe_suffix else ""
271
594
  raw = path / f"{slug}-{raw_label}{suffix_part}.{extension}"
272
595
  try:
273
596
  home = _Path.home().resolve()
@@ -296,27 +619,46 @@ def read_synthesis_file(path: str) -> str:
296
619
  raise SystemExit(2)
297
620
 
298
621
 
299
- def persist_report(report: schema.Report) -> dict[str, int]:
622
+ def _scoped_store_db(args: argparse.Namespace) -> Path | None:
623
+ """Scoped runs write findings inside the save dir, matching scoped reads."""
624
+ save_dir = getattr(args, "save_dir", None)
625
+ if save_dir:
626
+ return Path(save_dir).expanduser().resolve() / "research.db"
627
+ return None
628
+
629
+
630
+ def persist_report(report: schema.Report, store_db: Path | None = None) -> dict[str, int]:
300
631
  import store
301
632
 
302
- store.init_db()
303
- topic_row = store.add_topic(report.topic)
304
- topic_id = topic_row["id"]
305
- source_mode = ",".join(sorted(report.items_by_source)) or "v3"
306
- run_id = store.record_run(topic_id, source_mode=source_mode, status="running")
307
- try:
308
- findings = store.findings_from_report(report)
309
- counts = store.store_findings(run_id, topic_id, findings)
310
- store.update_run(
311
- run_id,
312
- status="completed",
313
- findings_new=counts["new"],
314
- findings_updated=counts["updated"],
315
- )
316
- return counts
317
- except Exception as exc:
318
- store.update_run(run_id, status="failed", error_message=str(exc)[:500])
319
- raise
633
+ private_corpus = _report_has_private_corpus(report)
634
+ with store.scoped_db(store_db):
635
+ if private_corpus:
636
+ store.ensure_private_db_files()
637
+ store.init_db()
638
+ if private_corpus:
639
+ store.ensure_private_db_files()
640
+ topic_row = store.add_topic(report.topic, update_existing=False)
641
+ topic_id = topic_row["id"]
642
+ source_mode = ",".join(sorted(report.items_by_source)) or "v3"
643
+ run_id = store.record_run(topic_id, source_mode=source_mode, status="running")
644
+ try:
645
+ findings = store.findings_from_report(report)
646
+ if private_corpus:
647
+ store.ensure_private_db_files()
648
+ counts = store.store_findings(run_id, topic_id, findings)
649
+ store.update_run(
650
+ run_id,
651
+ status="completed",
652
+ findings_new=counts["new"],
653
+ findings_updated=counts["updated"],
654
+ )
655
+ return counts
656
+ except Exception as exc:
657
+ store.update_run(run_id, status="failed", error_message=str(exc)[:500])
658
+ raise
659
+ finally:
660
+ if private_corpus:
661
+ store.ensure_private_db_files()
320
662
 
321
663
 
322
664
  def build_parser() -> argparse.ArgumentParser:
@@ -326,38 +668,163 @@ def build_parser() -> argparse.ArgumentParser:
326
668
  )
327
669
  parser.add_argument("topic", nargs="*", help="Research topic")
328
670
  parser.add_argument("--emit", default="compact", choices=["compact", "json", "context", "md", "html", "brief"])
671
+ parser.add_argument(
672
+ "--register",
673
+ choices=registers.REGISTER_NAMES,
674
+ default=None,
675
+ help="Audience synthesis preset for the standard brief (default, exec, dev, creator, eli5)",
676
+ )
677
+ parser.add_argument(
678
+ "--json-profile",
679
+ default="agent",
680
+ choices=["agent", "raw"],
681
+ help="JSON export profile for --emit=json (default: agent)",
682
+ )
329
683
  parser.add_argument("--search", help="Comma-separated source list")
330
684
  parser.add_argument("--quick", action="store_true", help="Lower-latency retrieval profile")
331
685
  parser.add_argument("--deep", action="store_true", help="Higher-recall retrieval profile")
686
+ freshness_group = parser.add_mutually_exclusive_group()
687
+ freshness_group.add_argument(
688
+ "--verify-freshness",
689
+ action="store_true",
690
+ default=None,
691
+ help="Re-check source-grounded claims after research, or verify the cached report when no topic is supplied",
692
+ )
693
+ freshness_group.add_argument(
694
+ "--no-verify-freshness",
695
+ dest="verify_freshness",
696
+ action="store_false",
697
+ help="Disable freshness verification configured by LAST30DAYS_VERIFY_FRESHNESS",
698
+ )
699
+ parser.add_argument(
700
+ "--drill",
701
+ metavar="TARGET",
702
+ help="Deep follow-up on a cluster from the fresh last-report.json cache",
703
+ )
704
+ parser.add_argument(
705
+ "--discover",
706
+ metavar="DOMAIN",
707
+ nargs="?",
708
+ const="",
709
+ default=None,
710
+ help=(
711
+ "Sweep river listings and rank the topics accelerating in a domain; "
712
+ "each survivor gets a full research pass. Bare --discover (no domain) "
713
+ "runs global trending across every feed's hot list"
714
+ ),
715
+ )
716
+ parser.add_argument(
717
+ "--discover-shallow",
718
+ action="store_true",
719
+ help=(
720
+ "Skip the per-topic research pass during --discover: rank on listing "
721
+ "evidence only (faster, thinner; the confidence floor still applies)"
722
+ ),
723
+ )
724
+ parser.add_argument(
725
+ "--nominate-only",
726
+ action="store_true",
727
+ help=(
728
+ "Leg 1 of the host-judged discovery protocol: sweep, write the "
729
+ "nominations bundle for host judgment, and stop (no judging, no "
730
+ "enrichment). Requires --discover"
731
+ ),
732
+ )
733
+ parser.add_argument(
734
+ "--judgments",
735
+ metavar="PATH",
736
+ help=(
737
+ "Leg 2 of the discovery protocol: resume from the nominations "
738
+ "bundle, applying the host judgments file at PATH. Requires "
739
+ "--discover"
740
+ ),
741
+ )
742
+ parser.add_argument(
743
+ "--finalize",
744
+ action="store_true",
745
+ help=(
746
+ "Leg 3 of the discovery protocol: apply host angles, render the "
747
+ "final discovery brief, and record the topic queue. Requires "
748
+ "--discover"
749
+ ),
750
+ )
751
+ parser.add_argument(
752
+ "--angles",
753
+ metavar="PATH",
754
+ help=(
755
+ "Optional host angles file for --discover --finalize (omitting it "
756
+ "ships the brief without angle lines)"
757
+ ),
758
+ )
332
759
  parser.add_argument("--debug", action="store_true", help="Enable HTTP debug logging")
333
760
  parser.add_argument("--mock", action="store_true", help="Use mock retrieval fixtures")
761
+ parser.add_argument(
762
+ "--record-fixtures",
763
+ metavar="DIR",
764
+ help=argparse.SUPPRESS,
765
+ )
334
766
  parser.add_argument("--diagnose", action="store_true", help="Print provider and source availability")
335
767
  parser.add_argument("--preflight", action="store_true",
336
768
  help="Print a safe human-readable permission preflight")
769
+ parser.add_argument("--welcome", action="store_true",
770
+ help="Print the first-run welcome text (engine-owned; relay verbatim)")
337
771
  parser.add_argument("--preflight-report-on-save-dir", help=argparse.SUPPRESS)
338
772
  parser.add_argument("--no-browser-cookies", action="store_true",
339
773
  help="Disable browser-cookie extraction even when FROM_BROWSER is configured")
340
774
  parser.add_argument("--save-dir", help="Optional directory for saving the rendered output")
775
+ parser.add_argument(
776
+ "--resolve-save-dir", action="store_true",
777
+ help="Print the skill save directory from flags/config, then exit without research",
778
+ )
779
+ parser.add_argument(
780
+ "--corpus",
781
+ action="append",
782
+ default=[],
783
+ metavar="DIR",
784
+ help="Add a local .md/.txt/.pdf directory as a private ranked source (repeatable)",
785
+ )
786
+ parser.add_argument(
787
+ "--corpus-all-time",
788
+ action="store_true",
789
+ help="Include matching corpus files older than the research window",
790
+ )
341
791
  parser.add_argument("--output", help="Optional exact file path for saving the rendered output")
342
792
  parser.add_argument("--synthesis-file", help="Markdown synthesis to embed in --emit=html output")
343
793
  parser.add_argument("--publish-html", action="store_true",
344
794
  help="Publish --emit=html output to ht-ml.app (explicit opt-in; public by default)")
795
+ parser.add_argument("--publish", action="store_true",
796
+ help="With 'library feed', publish the HTML index and briefs (explicit opt-in; public by default); feed.xml remains local")
345
797
  parser.add_argument("--publish-password",
346
- help="Optional shared password for --publish-html; prefer LAST30DAYS_PUBLISH_PASSWORD to avoid exposing secrets in process lists")
798
+ help="Optional shared password for --publish-html or 'library feed --publish'; prefer LAST30DAYS_PUBLISH_PASSWORD to avoid exposing secrets in process lists")
347
799
  parser.add_argument("--store", action="store_true", help="Persist ranked findings to the SQLite research store")
348
800
  parser.add_argument("--x-handle", help="X handle for targeted supplemental search")
349
801
  parser.add_argument("--x-related", help="Comma-separated related X handles (searched with lower weight)")
802
+ parser.add_argument(
803
+ "--x-posts",
804
+ dest="x_posts",
805
+ metavar="PATH",
806
+ help=(
807
+ "Path to a last30days-x-posts/1 JSON envelope of posts the hosting "
808
+ "model fetched through its X connector; replaces the engine's X "
809
+ "fetch for this run. A file path only (never inline JSON); on a "
810
+ "comparison run use the per-entity x_posts field of --competitors-plan."
811
+ ),
812
+ )
350
813
  parser.add_argument("--web-backend", default="auto",
351
- choices=["auto", "brave", "exa", "serper", "parallel", "none"],
352
- help="Web search backend (default: auto, tries Brave then Exa then Serper then Parallel)")
814
+ choices=["auto", "brave", "exa", "serper", "parallel", "parallel-mcp", "keyless", "none"],
815
+ help="Web search backend (default: auto; parallel-mcp explicitly opts into the "
816
+ "anonymous hosted MCP; keyless forces the zero-key floor)")
817
+ parser.add_argument("--perplexity-search-type", choices=["web", "fast"],
818
+ help="Search backend for direct Perplexity Search API and Agent web_search; overrides LAST30DAYS_PERPLEXITY_SEARCH_TYPE. Does not enable the paid source or select an Agent preset.")
353
819
  parser.add_argument("--deep-research", action="store_true",
354
- help="Use Perplexity Deep Research (~$0.90/query) for in-depth analysis. Requires PERPLEXITY_API_KEY or OPENROUTER_API_KEY.")
820
+ help="Use at most one Perplexity Deep Research run. Direct PERPLEXITY_API_KEY uses the Agent API background path; OPENROUTER_API_KEY keeps the synchronous Sonar fallback; cannot be combined with competitor or vs-mode.")
355
821
  parser.add_argument("--hiring-signals", action="store_true",
356
822
  help="Analyze public jobs/careers postings as evidence-backed company focus signals.")
357
823
  parser.add_argument("--plan", help="JSON query plan (skips internal LLM planner). Can be a JSON string or a file path.")
358
824
  parser.add_argument("--save-suffix", help="Suffix for saved output filename (e.g., 'gemini' → kanye-west-raw-gemini.md)")
359
825
  parser.add_argument("--subreddits", help="Comma-separated broad/category subreddit names to search (e.g., SaaS,Entrepreneur)")
360
826
  parser.add_argument("--dedicated-subreddits", help="Comma-separated entity-home subreddit names (e.g., Kanye,WestSubEver). Pulled in full (top+hot+new) and exempt from the relevance floor since the whole sub is the topic.")
827
+ parser.add_argument("--devto-tags", help="Comma-separated dev.to tags, lowercase alphanumeric (e.g., claudecode,ai)")
361
828
  parser.add_argument("--tiktok-hashtags", help="Comma-separated TikTok hashtags without # (e.g., tella,screenrecording)")
362
829
  parser.add_argument("--tiktok-creators", help="Comma-separated TikTok creator handles (e.g., TellaHQ,taborplace)")
363
830
  parser.add_argument("--ig-creators", help="Comma-separated Instagram creator handles (e.g., tella.tv,laborstories)")
@@ -366,7 +833,7 @@ def build_parser() -> argparse.ArgumentParser:
366
833
  "--lookback-days",
367
834
  dest="lookback_days",
368
835
  type=int,
369
- default=30,
836
+ default=None,
370
837
  help="Number of days to look back for research (default: 30, watchlist uses 90)",
371
838
  )
372
839
  parser.add_argument(
@@ -378,10 +845,63 @@ def build_parser() -> argparse.ArgumentParser:
378
845
  "When set, --days looks back from this date instead of today."
379
846
  ),
380
847
  )
848
+ parser.add_argument("--max-results", dest="max_results", type=int,
849
+ help="Override the final ranked-pool cap (pool_limit/rerank_limit) from the depth profile. "
850
+ "Use for high-volume topics where the default (deep=60) under-covers. See issue #716.")
851
+ parser.add_argument("--max-per-source", dest="max_per_source", type=int,
852
+ help="Override the per-stream cap (per_stream_limit) applied to each (source, subquery) before "
853
+ "pooling. Raising it increases unique-item yield when one source has many relevant items. "
854
+ "See issue #716.")
855
+ parser.add_argument("--max-source-fetches", dest="max_source_fetches", type=int,
856
+ help="Override the per-source fetch cap (MAX_SOURCE_FETCHES, default x=2) that limits how many "
857
+ "subqueries actually fetch a capped source. Raise it so every X subquery in a multi-angle "
858
+ "--plan runs instead of just the first two. See issue #716.")
381
859
  parser.add_argument("--auto-resolve", action="store_true",
382
860
  help="Use web search to discover subreddits/handles before planning (for platforms without WebSearch)")
383
861
  parser.add_argument("--github-user", help="GitHub username for person-mode search (e.g., steipete)")
384
862
  parser.add_argument("--github-repo", help="Comma-separated owner/repo for project-mode search (e.g., openclaw/openclaw,paperclipai/paperclip)")
863
+ parser.add_argument(
864
+ "--trustpilot-domain",
865
+ help=(
866
+ "Trustpilot review-page domain for the topic (e.g., www.thriftbooks.com). "
867
+ "Used verbatim, bypasses the brand-shape gate, and auto-activates the "
868
+ "opt-in Trustpilot source for this run (unless EXCLUDE_SOURCES=trustpilot). "
869
+ "Find the domain with `trustpilot-pp-cli search '<name>'`."
870
+ ),
871
+ )
872
+ parser.add_argument(
873
+ "--amazon-query",
874
+ help=(
875
+ "Product keyword the amazon source searches, when that source is active. "
876
+ "Defaults to the topic. Supply it whenever the topic is not the product: "
877
+ "a person topic searches their company's product line "
878
+ "(--amazon-query='June Oven'), and a brand searches brand-plus-category "
879
+ "(--amazon-query='Weber grill', not 'Weber' -- a bare brand keyword lands "
880
+ "on an ad-heavy page that can miss the brand's own bestsellers). "
881
+ "Requires the brightdata CLI on PATH and logged in."
882
+ ),
883
+ )
884
+ parser.add_argument(
885
+ "--meta-ads-page",
886
+ help=(
887
+ "Meta Ad Library page id for the topic's advertiser, when the meta_ads "
888
+ "source is active. Skips name-based page resolution and its discovery "
889
+ "credit. Accepts a bare numeric page id (e.g. 123456789012345) or an Ad "
890
+ "Library URL carrying view_all_page_id. A facebook.com vanity URL is not "
891
+ "a page id and is rejected. Use it when a brand advertises under product "
892
+ "names, or when resolution picked the wrong company."
893
+ ),
894
+ )
895
+ parser.add_argument(
896
+ "--telegram-sources",
897
+ help=(
898
+ "Comma-separated list of public Telegram channel handles or t.me URLs. "
899
+ "Auto-activates the opt-in Telegram source for this run. "
900
+ "Accepts: bare handle (aipost), @handle (@aipost), "
901
+ "t.me URL (https://t.me/aipost), or preview URL (https://t.me/s/aipost). "
902
+ "Rejects joinchat links and numeric -100 supergroup IDs."
903
+ ),
904
+ )
385
905
  parser.add_argument(
386
906
  "--competitors",
387
907
  nargs="?",
@@ -434,7 +954,8 @@ def parse_competitors_plan(raw: str | None) -> dict[str, dict]:
434
954
  plan_str = raw
435
955
  if os.path.isfile(plan_str):
436
956
  try:
437
- plan_str = open(plan_str, encoding="utf-8").read()
957
+ with open(plan_str, encoding="utf-8") as f:
958
+ plan_str = f.read()
438
959
  except (OSError, UnicodeDecodeError) as exc:
439
960
  sys.stderr.write(f"[CompetitorsPlan] Cannot read plan file: {exc}\n")
440
961
  raise SystemExit(2)
@@ -451,7 +972,8 @@ def parse_competitors_plan(raw: str | None) -> dict[str, dict]:
451
972
  raise SystemExit(2)
452
973
  known_fields = {
453
974
  "x_handle", "x_related", "subreddits",
454
- "github_user", "github_repos", "context",
975
+ "github_user", "github_repos", "trustpilot_domain", "context",
976
+ "x_posts",
455
977
  }
456
978
  normalized: dict[str, dict] = {}
457
979
  for entity, entry in parsed.items():
@@ -468,7 +990,8 @@ def parse_competitors_plan(raw: str | None) -> dict[str, dict]:
468
990
  f"{sorted(unknown)}; ignoring.\n"
469
991
  )
470
992
  normalized[entity.strip().lower()] = {
471
- k: v for k, v in entry.items() if k in known_fields
993
+ **{k: v for k, v in entry.items() if k in known_fields},
994
+ "_name": entity.strip(),
472
995
  }
473
996
  return normalized
474
997
 
@@ -516,6 +1039,15 @@ def subrun_kwargs_for(
516
1039
  if isinstance(github_repos, list):
517
1040
  github_repos = [r.strip() for r in github_repos if r.strip() and "/" in r.strip()] or None
518
1041
 
1042
+ trustpilot_domain = _choose("trustpilot_domain", "trustpilot_domain")
1043
+ if isinstance(trustpilot_domain, str):
1044
+ trustpilot_domain = trustpilot_domain.strip() or None
1045
+ # Provenance: a plan-supplied domain is user-set (verbatim-final); one that
1046
+ # only came from auto_resolve is a hint that retries via search on a miss.
1047
+ trustpilot_domain_is_hint = bool(
1048
+ trustpilot_domain and not plan_entry.get("trustpilot_domain")
1049
+ )
1050
+
519
1051
  context = plan_entry.get("context") or resolved.get("context") or ""
520
1052
 
521
1053
  return {
@@ -524,20 +1056,98 @@ def subrun_kwargs_for(
524
1056
  "subreddits": subreddits,
525
1057
  "github_user": github_user,
526
1058
  "github_repos": github_repos,
1059
+ "trustpilot_domain": trustpilot_domain,
1060
+ "_trustpilot_domain_is_hint": trustpilot_domain_is_hint,
527
1061
  "_context": context,
528
1062
  }
529
1063
 
530
1064
 
531
- COMPETITORS_MIN = 1
532
- COMPETITORS_MAX = 6
533
- COMPETITORS_DEFAULT = 2
1065
+ COMPETITORS_MIN = competitors_mod.COMPETITORS_MIN
1066
+ COMPETITORS_MAX = competitors_mod.COMPETITORS_MAX
1067
+ COMPETITORS_DEFAULT = competitors_mod.COMPETITORS_DEFAULT
1068
+
1069
+
1070
+ def truncate_comparison_entities(entities: list[str], *, warn: bool = True) -> list[str]:
1071
+ """Cap a vs-entity list at COMPARISON_ENTITY_MAX; optionally warn on stderr."""
1072
+ ceiling = competitors_mod.COMPARISON_ENTITY_MAX
1073
+ if len(entities) <= ceiling:
1074
+ return list(entities)
1075
+ kept = entities[:ceiling]
1076
+ dropped = entities[ceiling:]
1077
+ if warn:
1078
+ sys.stderr.write(
1079
+ f"[Competitors] vs-topic has {len(entities)} entities; "
1080
+ f"using first {ceiling}, dropped: {', '.join(dropped)}\n"
1081
+ )
1082
+ return kept
1083
+
1084
+
1085
+ def apply_vs_competitor_routing(
1086
+ topic: str,
1087
+ *,
1088
+ competitors_flag: int | None,
1089
+ comp_enabled: bool,
1090
+ comp_count: int,
1091
+ comp_explicit: list[str],
1092
+ comp_plan: dict[str, dict] | None = None,
1093
+ ) -> tuple[str, bool, int, list[str]]:
1094
+ """Apply vs-string / plan routing on top of resolve_competitors_args.
1095
+
1096
+ Precedence for *who* runs:
1097
+ 1. ``--competitors-list`` (explicit peers; topic unchanged)
1098
+ 2. Pure discover-N (``--competitors`` without list or plan) — topic
1099
+ unchanged, even if it contains ``vs``
1100
+ 3. vs-string split (first entity becomes main topic) — used for bare
1101
+ vs-topics and vs-topic + ``--competitors-plan``
1102
+ 4. ``--competitors-plan`` keys as peers when there is no vs-string
1103
+ (including when ``--competitors N`` is also set)
1104
+ """
1105
+ from lib import planner as _planner
1106
+
1107
+ if comp_explicit:
1108
+ return topic, True, len(comp_explicit), list(comp_explicit)
1109
+
1110
+ # Preserve discover-N semantics: numeric flag without plan/list must not
1111
+ # rewrite a vs-string into named peers.
1112
+ if competitors_flag is not None and not comp_plan:
1113
+ return topic, True, comp_count, []
1114
+
1115
+ vs_entities = truncate_comparison_entities(
1116
+ _planner._comparison_entities(topic, uncapped=True),
1117
+ warn=True,
1118
+ )
1119
+ if len(vs_entities) >= 2:
1120
+ main, peers = vs_entities[0], vs_entities[1:]
1121
+ sys.stderr.write(
1122
+ f"[Competitors] vs-mode: routing to N-pass fanout: "
1123
+ f"{main} vs {' vs '.join(peers)}\n"
1124
+ )
1125
+ return main, True, len(peers), peers
1126
+
1127
+ if comp_plan:
1128
+ plan_peers = [
1129
+ (entry.get("_name") or key)
1130
+ for key, entry in comp_plan.items()
1131
+ ]
1132
+ plan_peers = [name for name in plan_peers if name]
1133
+ if len(plan_peers) > COMPETITORS_MAX:
1134
+ sys.stderr.write(
1135
+ f"[Competitors] --competitors-plan has {len(plan_peers)} entries, "
1136
+ f"clamping to {COMPETITORS_MAX}.\n"
1137
+ )
1138
+ plan_peers = plan_peers[:COMPETITORS_MAX]
1139
+ return topic, True, len(plan_peers), plan_peers
1140
+
1141
+ return topic, comp_enabled, comp_count, comp_explicit
534
1142
 
535
1143
 
536
1144
  def resolve_competitors_args(args: argparse.Namespace) -> tuple[bool, int, list[str]]:
537
- """Normalize --competitors / --competitors-list into (enabled, count, explicit_list).
1145
+ """Normalize competitors flags into (enabled, count, explicit_list).
538
1146
 
539
- - (False, 0, []) when neither flag is set.
540
- - An explicit list always wins; count is derived from list length.
1147
+ - (False, 0, []) when neither flag, list, nor plan is set.
1148
+ - An explicit ``--competitors-list`` always wins; count is derived from list length.
1149
+ - ``--competitors-plan`` alone enables mode with an empty peer list; vs-routing
1150
+ fills peers from the vs-string or plan keys.
541
1151
  - A numeric count outside [1, 6] is clamped with a stderr warning.
542
1152
  - count <= 0 (explicit) raises SystemExit(2).
543
1153
  """
@@ -556,8 +1166,9 @@ def resolve_competitors_args(args: argparse.Namespace) -> tuple[bool, int, list[
556
1166
  competitors_flag = args.competitors
557
1167
  list_present = bool(explicit_list)
558
1168
  flag_present = competitors_flag is not None
1169
+ plan_present = bool(getattr(args, "competitors_plan", None))
559
1170
 
560
- if not list_present and not flag_present:
1171
+ if not list_present and not flag_present and not plan_present:
561
1172
  return False, 0, []
562
1173
 
563
1174
  if list_present:
@@ -575,19 +1186,22 @@ def resolve_competitors_args(args: argparse.Namespace) -> tuple[bool, int, list[
575
1186
  count = COMPETITORS_MAX
576
1187
  return True, count, explicit_list
577
1188
 
578
- # flag_present, no explicit list
579
- count = competitors_flag
580
- if count < COMPETITORS_MIN:
581
- sys.stderr.write(
582
- f"[Competitors] --competitors must be >= {COMPETITORS_MIN} (got {count}).\n"
583
- )
584
- raise SystemExit(2)
585
- if count > COMPETITORS_MAX:
586
- sys.stderr.write(
587
- f"[Competitors] --competitors={count} exceeds max {COMPETITORS_MAX}; clamping.\n"
588
- )
589
- count = COMPETITORS_MAX
590
- return True, count, []
1189
+ if flag_present:
1190
+ count = competitors_flag
1191
+ if count < COMPETITORS_MIN:
1192
+ sys.stderr.write(
1193
+ f"[Competitors] --competitors must be >= {COMPETITORS_MIN} (got {count}).\n"
1194
+ )
1195
+ raise SystemExit(2)
1196
+ if count > COMPETITORS_MAX:
1197
+ sys.stderr.write(
1198
+ f"[Competitors] --competitors={count} exceeds max {COMPETITORS_MAX}; clamping.\n"
1199
+ )
1200
+ count = COMPETITORS_MAX
1201
+ return True, count, []
1202
+
1203
+ # plan_present alone: enable; peers filled by apply_vs_competitor_routing.
1204
+ return True, 0, []
591
1205
 
592
1206
 
593
1207
  def _missing_sources_for_promo(diag: dict[str, object]) -> str | None:
@@ -595,8 +1209,8 @@ def _missing_sources_for_promo(diag: dict[str, object]) -> str | None:
595
1209
  missing = []
596
1210
  if "reddit" not in available:
597
1211
  missing.append("reddit")
598
- if "x" not in available:
599
- missing.append("x")
1212
+ # X is optional. A successful run without X must reach the research output
1213
+ # without an authentication or browser-cookie promo in front of it.
600
1214
  # The web promo nudges toward a paid backend for higher-quality web search.
601
1215
  # Grounding is now available keyless on non-native hosts, so key the promo on
602
1216
  # the absence of a *paid* backend, not on grounding availability. Suppress it
@@ -606,11 +1220,30 @@ def _missing_sources_for_promo(diag: dict[str, object]) -> str | None:
606
1220
  missing.append("web")
607
1221
  if not missing:
608
1222
  return None
609
- if "reddit" in missing and "x" in missing:
610
- return "both"
611
1223
  return missing[0]
612
1224
 
613
1225
 
1226
+ def _optional_x_omission_text(
1227
+ diag: dict[str, object],
1228
+ requested_sources: list[str] | None,
1229
+ ) -> str | None:
1230
+ """Return a non-blocking post-result note for a default run without X.
1231
+
1232
+ Explicit ``--search`` runs already define their intended source boundary,
1233
+ so they do not need an omission note. Doctor/diagnose remains the place for
1234
+ X setup or repair instructions.
1235
+ """
1236
+ if requested_sources is not None:
1237
+ return None
1238
+ available = set(diag.get("available_sources") or [])
1239
+ if "x" in available:
1240
+ return None
1241
+ return (
1242
+ "Optional source omitted: X/Twitter was not enabled; research "
1243
+ "continued with the available sources."
1244
+ )
1245
+
1246
+
614
1247
  def _show_runtime_ui(
615
1248
  report: schema.Report,
616
1249
  progress: ui.ProgressDisplay,
@@ -672,32 +1305,28 @@ def _report_cache_ttl_seconds(config: dict[str, object]) -> int:
672
1305
 
673
1306
 
674
1307
  def _is_report_cache_fresh(timestamp: object, ttl_seconds: int) -> bool:
675
- if ttl_seconds <= 0:
676
- return False
677
- if not isinstance(timestamp, str) or not timestamp:
678
- return False
679
- try:
680
- created_at = datetime.datetime.fromisoformat(timestamp)
681
- except ValueError:
682
- return False
683
- if created_at.tzinfo is None:
684
- created_at = created_at.replace(tzinfo=datetime.timezone.utc)
685
- age = datetime.datetime.now(datetime.timezone.utc) - created_at.astimezone(
686
- datetime.timezone.utc
687
- )
688
- return age.total_seconds() <= ttl_seconds
1308
+ return env.is_timestamp_fresh(timestamp, ttl_seconds)
689
1309
 
690
1310
 
691
1311
  def _write_last_run(
692
1312
  topic: str,
693
1313
  report: "schema.Report",
694
1314
  entity_reports: list[tuple[str, schema.Report]] | None = None,
695
- ) -> None:
1315
+ *,
1316
+ x_envelope_sha256: str | None = None,
1317
+ ) -> bool:
1318
+ # ``x_envelope_sha256`` binds the cached report to the --x-posts file it
1319
+ # was built from; _load_last_report_cache misses on any mismatch.
696
1320
  try:
697
1321
  if env.CONFIG_DIR is None:
698
- return
1322
+ return False
699
1323
  target = env.CONFIG_DIR
700
- target.mkdir(parents=True, exist_ok=True)
1324
+ cached_reports = entity_reports or [(report.topic, report)]
1325
+ has_private_corpus = any(
1326
+ cached_report.items_by_source.get("corpus")
1327
+ for _, cached_report in cached_reports
1328
+ )
1329
+ _ensure_output_directory(target, private=has_private_corpus)
701
1330
  counts = {source: len(items) for source, items in report.items_by_source.items()}
702
1331
  payload = {
703
1332
  "topic": topic,
@@ -707,38 +1336,55 @@ def _write_last_run(
707
1336
  "report_cache": str(target / "last-report.json"),
708
1337
  "comparison": bool(entity_reports),
709
1338
  }
710
- (target / "last-run.json").write_text(json.dumps(payload, indent=2))
711
- cached_reports = entity_reports or [(report.topic, report)]
1339
+ write_private_text(target / "last-run.json", json.dumps(payload, indent=2))
712
1340
  cache_payload = {
713
1341
  "schema": REPORT_CACHE_VERSION,
714
1342
  "topic": topic,
715
1343
  "timestamp": payload["timestamp"],
716
1344
  "comparison": bool(entity_reports),
1345
+ "x_envelope_sha256": x_envelope_sha256 or None,
717
1346
  "reports": [
718
1347
  {"entity": label, "report": schema.to_dict(cached_report)}
719
1348
  for label, cached_report in cached_reports
720
1349
  ],
721
1350
  }
722
- (target / "last-report.json").write_text(json.dumps(cache_payload, indent=2))
723
- except Exception:
724
- pass
1351
+ report_cache_path = target / "last-report.json"
1352
+ write_private_text(report_cache_path, json.dumps(cache_payload, indent=2))
1353
+ if has_private_corpus and os.name != "posix":
1354
+ report_cache_path.chmod(0o600)
1355
+ return True
1356
+ except Exception as exc:
1357
+ # Never fatal, but never silent either (#787's lesson): callers that
1358
+ # promise cache state (drill chaining) branch on the return value.
1359
+ sys.stderr.write(f"[last30days] warning: could not write run cache: {exc}\n")
1360
+ return False
725
1361
 
726
1362
 
727
1363
  def _load_last_report_cache(
728
- topic: str,
1364
+ topic: str | None,
729
1365
  ttl_seconds: int = DEFAULT_REPORT_CACHE_TTL_SECONDS,
1366
+ *,
1367
+ x_envelope_sha256: str | None = None,
730
1368
  ) -> tuple[schema.Report, list[tuple[str, schema.Report]] | None, Path] | None:
731
1369
  cache_path = _last_report_cache_path()
732
1370
  if cache_path is None or not cache_path.exists():
733
1371
  return None
734
1372
  try:
735
1373
  payload = json.loads(cache_path.read_text(encoding="utf-8"))
1374
+ if not isinstance(payload, dict):
1375
+ raise TypeError("report cache payload must be a JSON object")
736
1376
  if payload.get("schema") != REPORT_CACHE_VERSION:
737
1377
  return None
738
1378
  if not _is_report_cache_fresh(payload.get("timestamp"), ttl_seconds):
739
1379
  return None
1380
+ # A report built from a --x-posts envelope is only reusable with the
1381
+ # same envelope content; a digest on either side that does not match
1382
+ # the other is a miss.
1383
+ cached_digest = payload.get("x_envelope_sha256") or None
1384
+ if (cached_digest or x_envelope_sha256) and cached_digest != x_envelope_sha256:
1385
+ return None
740
1386
  cached_topic = str(payload.get("topic") or "").strip().lower()
741
- if cached_topic != topic.strip().lower():
1387
+ if topic is not None and cached_topic != topic.strip().lower():
742
1388
  return None
743
1389
  reports_payload = payload.get("reports") or []
744
1390
  if not reports_payload:
@@ -757,30 +1403,1050 @@ def _load_last_report_cache(
757
1403
  return None
758
1404
  return entity_reports[0][1], entity_reports, cache_path
759
1405
  return entity_reports[0][1], None, cache_path
760
- except Exception:
1406
+ except (OSError, json.JSONDecodeError, KeyError, TypeError, ValueError) as exc:
1407
+ sys.stderr.write(
1408
+ f"[last30days] Could not read report cache {cache_path}: "
1409
+ f"{type(exc).__name__}: {exc}\n"
1410
+ )
761
1411
  return None
762
1412
 
763
1413
 
764
- def _render_save_and_print(
765
- args: argparse.Namespace,
1414
+ def _config_truthy(value: object) -> bool:
1415
+ return str(value or "").strip().lower() in {"1", "true", "yes", "on"}
1416
+
1417
+
1418
+ def _freshness_enabled(args: argparse.Namespace, config: dict[str, object]) -> bool:
1419
+ if args.verify_freshness is not None:
1420
+ return bool(args.verify_freshness)
1421
+ return _config_truthy(config.get("LAST30DAYS_VERIFY_FRESHNESS"))
1422
+
1423
+
1424
+ def _update_cached_freshness(
1425
+ cache_path: Path,
766
1426
  report: schema.Report,
767
1427
  entity_reports: list[tuple[str, schema.Report]] | None,
768
- synthesis_md: str | None,
769
- config: dict[str, object],
770
- ) -> int:
771
- fun_level = str(config.get("FUN_LEVEL", "medium")).lower()
772
- # Comparison HTML is the one case where the saved file's title and content
773
- # have to be overridden away from the leading entity's report. Compute the
774
- # gate once so the footer-display and save-output paths can't disagree.
775
- is_comparison_html = bool(entity_reports) and args.emit == "html"
776
- footer_save_path = None
777
- if args.output:
778
- footer_save_path = compute_output_path_display(args.output)
779
- elif args.save_dir:
780
- save_topic_for_display = comparison_topic(entity_reports) if is_comparison_html else report.topic
781
- footer_save_path = compute_save_path_display(
782
- args.save_dir, save_topic_for_display, args.save_suffix or "", args.emit
783
- )
1428
+ ) -> bool:
1429
+ """Rewrite cached report bodies without extending the research-cache TTL."""
1430
+ try:
1431
+ payload = json.loads(cache_path.read_text(encoding="utf-8"))
1432
+ if not isinstance(payload, dict) or payload.get("schema") != REPORT_CACHE_VERSION:
1433
+ return False
1434
+ existing = payload.get("reports") or []
1435
+ if entity_reports:
1436
+ cached_reports = entity_reports
1437
+ else:
1438
+ label = (
1439
+ str(existing[0].get("entity") or report.topic)
1440
+ if existing and isinstance(existing[0], dict)
1441
+ else report.topic
1442
+ )
1443
+ cached_reports = [(label, report)]
1444
+ payload["reports"] = [
1445
+ {"entity": label, "report": schema.to_dict(cached_report)}
1446
+ for label, cached_report in cached_reports
1447
+ ]
1448
+ cache_path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
1449
+ return True
1450
+ except (OSError, json.JSONDecodeError, TypeError, ValueError) as exc:
1451
+ sys.stderr.write(
1452
+ f"[last30days] warning: could not update freshness cache: {exc}\n"
1453
+ )
1454
+ return False
1455
+
1456
+
1457
+ def _verify_report_set(
1458
+ report: schema.Report,
1459
+ entity_reports: list[tuple[str, schema.Report]] | None,
1460
+ *,
1461
+ allow_network: bool,
1462
+ ) -> None:
1463
+ reports = [item for _, item in entity_reports] if entity_reports else [report]
1464
+ for current_report in reports:
1465
+ freshness.verify_report(current_report, allow_network=allow_network)
1466
+ if not any(current_report.freshness_verdicts for current_report in reports):
1467
+ # An empty verdict list is a legitimate outcome, but a silent one has
1468
+ # already misled operators once; say why there is nothing to show.
1469
+ sys.stderr.write(
1470
+ "[last30days] Freshness verification found no re-checkable claims"
1471
+ " in this report; the verdict list is empty.\n"
1472
+ )
1473
+
1474
+
1475
+ def _run_cached_freshness(
1476
+ args: argparse.Namespace,
1477
+ config: dict[str, object],
1478
+ ) -> int:
1479
+ cached = _load_last_report_cache(
1480
+ None,
1481
+ ttl_seconds=_report_cache_ttl_seconds(config),
1482
+ )
1483
+ if cached is None:
1484
+ sys.stderr.write("[last30days] No fresh cached report; run a research pass first.\n")
1485
+ return 2
1486
+ report, entity_reports, cache_path = cached
1487
+ _verify_report_set(report, entity_reports, allow_network=not args.mock)
1488
+ if _update_cached_freshness(cache_path, report, entity_reports):
1489
+ sys.stderr.write(f"[last30days] Updated freshness verdicts in {cache_path}\n")
1490
+ else:
1491
+ sys.stderr.write("[last30days] warning: freshness cache update failed\n")
1492
+ return _render_save_and_print(args, report, entity_reports, None, config)
1493
+
1494
+
1495
+ def _drill_config(config: dict[str, object], sources: list[str]) -> dict[str, object]:
1496
+ """Enable configured comment enrichments for a deep follow-up."""
1497
+ drill_config = dict(config)
1498
+ include = {
1499
+ value.strip().lower()
1500
+ for value in str(config.get("INCLUDE_SOURCES") or "").split(",")
1501
+ if value.strip()
1502
+ }
1503
+ comment_flags = {
1504
+ "youtube": "youtube_comments",
1505
+ "tiktok": "tiktok_comments",
1506
+ "instagram": "instagram_comments",
1507
+ }
1508
+ include.update(comment_flags[source] for source in sources if source in comment_flags)
1509
+ if include:
1510
+ drill_config["INCLUDE_SOURCES"] = ",".join(sorted(include))
1511
+ drill_config["_drill_mode"] = True
1512
+ return drill_config
1513
+
1514
+
1515
+ def _run_drill(
1516
+ args: argparse.Namespace,
1517
+ config: dict[str, object],
1518
+ ) -> int:
1519
+ from lib import planner
1520
+
1521
+ cached = _load_last_report_cache(
1522
+ None,
1523
+ ttl_seconds=_report_cache_ttl_seconds(config),
1524
+ )
1525
+ if cached is None:
1526
+ sys.stderr.write(
1527
+ "[last30days] No fresh cached report; run a research pass first.\n"
1528
+ )
1529
+ return 2
1530
+ report, entity_reports, cache_path = cached
1531
+ if entity_reports:
1532
+ sys.stderr.write(
1533
+ "[last30days] Drill mode needs a single-topic cached report; "
1534
+ "run a research pass for one entity first.\n"
1535
+ )
1536
+ return 2
1537
+
1538
+ lookback_days = args.lookback_days
1539
+ if lookback_days is None:
1540
+ range_from = datetime.date.fromisoformat(report.range_from)
1541
+ range_to = datetime.date.fromisoformat(report.range_to)
1542
+ lookback_days = (range_to - range_from).days
1543
+ as_of_date = args.as_of_date or report.range_to
1544
+
1545
+ try:
1546
+ matched_clusters = planner.resolve_drill_clusters(report, args.drill)
1547
+ drill_plan = planner.build_drill_plan(
1548
+ report,
1549
+ args.drill,
1550
+ clusters=matched_clusters,
1551
+ )
1552
+ except planner.DrillTargetError as exc:
1553
+ sys.stderr.write(f"[last30days] {exc}\n")
1554
+ return 2
1555
+
1556
+ sources = list(drill_plan.source_weights)
1557
+ drill_config = _drill_config(config, sources)
1558
+ diag = pipeline.diagnose(drill_config, sources, safe=False)
1559
+ progress = ui.ProgressDisplay(
1560
+ f"{report.topic} — drill: {args.drill}",
1561
+ show_banner=True,
1562
+ )
1563
+ progress.start_processing()
1564
+ resolved = report.artifacts.get("resolved") or {}
1565
+ try:
1566
+ drill_report = pipeline.run(
1567
+ # Keep source gating anchored to the cached entity (for example,
1568
+ # StockTwits needs the original cashtag/finance context). The
1569
+ # external drill plan below remains cluster-focused.
1570
+ topic=report.topic,
1571
+ config=drill_config,
1572
+ depth="deep",
1573
+ requested_sources=sources,
1574
+ mock=args.mock,
1575
+ x_handle=(
1576
+ (args.x_handle or resolved.get("x_handle") or None)
1577
+ if "x" in sources else None
1578
+ ),
1579
+ x_related=(
1580
+ [value.strip() for value in args.x_related.split(",") if value.strip()]
1581
+ if (args.x_related and "x" in sources) else None
1582
+ ),
1583
+ web_backend=args.web_backend,
1584
+ external_plan=schema.to_dict(drill_plan),
1585
+ subreddits=(
1586
+ ([value.strip().removeprefix("r/") for value in args.subreddits.split(",") if value.strip()]
1587
+ if args.subreddits else list(resolved.get("subreddits") or []) or None)
1588
+ if "reddit" in sources else None
1589
+ ),
1590
+ tiktok_hashtags=(
1591
+ [value.strip().lstrip("#") for value in args.tiktok_hashtags.split(",") if value.strip()]
1592
+ if args.tiktok_hashtags else None
1593
+ ),
1594
+ tiktok_creators=(
1595
+ [value.strip().lstrip("@") for value in args.tiktok_creators.split(",") if value.strip()]
1596
+ if args.tiktok_creators else None
1597
+ ),
1598
+ ig_creators=(
1599
+ [value.strip().lstrip("@") for value in args.ig_creators.split(",") if value.strip()]
1600
+ if args.ig_creators else None
1601
+ ),
1602
+ lookback_days=lookback_days,
1603
+ as_of_date=as_of_date,
1604
+ github_user=(
1605
+ (args.github_user or resolved.get("github_user") or None)
1606
+ if "github" in sources else None
1607
+ ),
1608
+ github_repos=(
1609
+ ([value.strip() for value in args.github_repo.split(",") if value.strip()]
1610
+ if args.github_repo else list(resolved.get("github_repos") or []) or None)
1611
+ if "github" in sources else None
1612
+ ),
1613
+ trustpilot_domain=(
1614
+ (args.trustpilot_domain or resolved.get("trustpilot_domain") or None)
1615
+ if "trustpilot" in sources else None
1616
+ ),
1617
+ internal_subrun=True,
1618
+ corpus_dirs=args.corpus,
1619
+ corpus_all_time=args.corpus_all_time,
1620
+ )
1621
+ except Exception:
1622
+ progress.end_processing()
1623
+ raise
1624
+
1625
+ _show_runtime_ui(drill_report, progress, diag, suppress_web_promo=True)
1626
+ merged = pipeline.merge_drill_report(
1627
+ report,
1628
+ drill_report,
1629
+ matched_clusters,
1630
+ target=args.drill,
1631
+ )
1632
+ if _freshness_enabled(args, config):
1633
+ _verify_report_set(merged, None, allow_network=not args.mock)
1634
+ else:
1635
+ merged.freshness_verdicts = []
1636
+ if _write_last_run(report.topic, merged):
1637
+ sys.stderr.write(f"[last30days] Updated drill cache in {cache_path}\n")
1638
+ else:
1639
+ sys.stderr.write(
1640
+ "[last30days] warning: drill cache update failed; the next drill "
1641
+ "will see the pre-drill report\n"
1642
+ )
1643
+
1644
+ store_default = str(
1645
+ os.environ.get("LAST30DAYS_STORE")
1646
+ or config.get("LAST30DAYS_STORE")
1647
+ or ""
1648
+ ).lower()
1649
+ if args.store or store_default in {"1", "true", "yes"}:
1650
+ counts = persist_report(merged, store_db=_scoped_store_db(args))
1651
+ sys.stderr.write(
1652
+ f"[last30days] Stored {counts['new']} new, "
1653
+ f"{counts['updated']} updated findings\n"
1654
+ )
1655
+
1656
+ synthesis_md = None
1657
+ if args.synthesis_file:
1658
+ if args.emit == "html":
1659
+ synthesis_md = read_synthesis_file(args.synthesis_file)
1660
+ else:
1661
+ sys.stderr.write(
1662
+ "[last30days] Warning: --synthesis-file is only used with "
1663
+ "--emit=html; ignoring.\n"
1664
+ )
1665
+ return _render_save_and_print(args, merged, None, synthesis_md, config)
1666
+
1667
+
1668
+ def _save_discovery_output(
1669
+ rendered: str,
1670
+ *,
1671
+ domain: str,
1672
+ emit: str,
1673
+ save_dir: str,
1674
+ suffix: str = "",
1675
+ ) -> Path:
1676
+ directory = Path(save_dir).expanduser().resolve()
1677
+ directory.mkdir(parents=True, exist_ok=True)
1678
+ extension = "json" if emit == "json" else "md"
1679
+ safe_suffix = sanitize_suffix(suffix)
1680
+ suffix_part = f"-{safe_suffix}" if safe_suffix else ""
1681
+ stem = f"{slugify(domain)}-discover-raw{suffix_part}"
1682
+ date_str = datetime.datetime.now().strftime("%Y-%m-%d")
1683
+ candidates = [directory / f"{stem}.{extension}", directory / f"{stem}-{date_str}.{extension}"]
1684
+ candidates.extend(directory / f"{stem}-{date_str}-{index}.{extension}" for index in range(1, 100))
1685
+ encoded = rendered.encode("utf-8")
1686
+ for candidate in candidates:
1687
+ try:
1688
+ fd = os.open(candidate, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
1689
+ except FileExistsError:
1690
+ continue
1691
+ with os.fdopen(fd, "wb") as output:
1692
+ output.write(encoded)
1693
+ return candidate
1694
+ raise RuntimeError("Could not find a unique discovery output filename")
1695
+
1696
+
1697
+ def _pre_run_prior_state(
1698
+ prior: dict[str, object] | None, run_ref: str
1699
+ ) -> dict[str, object] | None:
1700
+ """Reconstruct the queue state a topic had BEFORE this run identity
1701
+ recorded it.
1702
+
1703
+ A row whose last_run_ref equals THIS run's run_ref was stamped by this
1704
+ very run's own earlier attempt (a finalize retry), so its surface_count
1705
+ already includes this run's surfacing: subtract it and keep the prior's
1706
+ covered state (covered_at intact) so the retry renders exactly like the
1707
+ first attempt did. Only when nothing remains after the subtraction AND
1708
+ the row was never covered is the topic genuinely first-ever (no prior).
1709
+ """
1710
+ if not prior or prior.get("last_run_ref") != run_ref:
1711
+ return prior
1712
+ previously = max(0, int(prior["surface_count"]) - 1)
1713
+ if previously == 0 and prior["status"] != "covered":
1714
+ return None
1715
+ adjusted = dict(prior)
1716
+ adjusted["surface_count"] = previously
1717
+ return adjusted
1718
+
1719
+
1720
+ def _annotate_and_record_discovery_queue(
1721
+ report: schema.DiscoveryReport,
1722
+ args: argparse.Namespace,
1723
+ config: dict[str, object],
1724
+ run_ref: str | None = None,
1725
+ ) -> schema.DiscoveryReport:
1726
+ """Stamp queue annotations onto report topics, then record this surfacing.
1727
+
1728
+ Order matters: annotations describe the queue state BEFORE this run, so
1729
+ each topic is matched first and recorded second. The queue is on by
1730
+ default; the resolved config value LAST30DAYS_DISCOVERY_QUEUE == "off"
1731
+ (env var or .env, via env.get_config) disables it. Scoped runs
1732
+ (--save-dir) write the scoped research.db, never the global one. Runs
1733
+ synchronously after the pipeline returns - this writes disk, so the
1734
+ abandon-on-timeout daemon-thread pattern is forbidden here.
1735
+
1736
+ ``run_ref`` overrides the run identity: the finalize leg passes the
1737
+ pending report's leg-2 run_ref through so a finalize retry records (and
1738
+ annotates) as the SAME run - store.record_discovery_surfacing skips the
1739
+ double-count, and rows this very run identity stamped are not "prior"
1740
+ state, so retries render identically instead of claiming a resurfacing.
1741
+ """
1742
+ queue_setting = str(config.get("LAST30DAYS_DISCOVERY_QUEUE") or "").strip().lower()
1743
+ if queue_setting == "off" or not report.topics:
1744
+ return report
1745
+
1746
+ import dataclasses
1747
+
1748
+ import store
1749
+
1750
+ run_ref = run_ref or f"discover:{report.domain or 'trending'}:{report.generated_at}"
1751
+ as_of = (report.generated_at or "")[:10] or report.range_to
1752
+ annotated: list[schema.DiscoveryTopic] = []
1753
+ with store.scoped_db(_scoped_store_db(args)):
1754
+ store.init_db()
1755
+ # Phase 1: match EVERY topic before recording ANY. Interleaving
1756
+ # match+record in one loop lets topic N fuzzy-match a same-anchor
1757
+ # sibling row this very run recorded seconds earlier, falsely
1758
+ # annotating a first-ever topic as "surfaced 2nd time".
1759
+ # A row stamped by THIS run identity is this run's own earlier
1760
+ # attempt (finalize retry), not prior state: reconstruct the pre-run
1761
+ # state (count minus this run's own surfacing, covered state kept)
1762
+ # so retries render identically for topics WITH history too.
1763
+ priors = [
1764
+ _pre_run_prior_state(prior, run_ref)
1765
+ for prior in (
1766
+ store.match_discovery_topic(topic.name) for topic in report.topics
1767
+ )
1768
+ ]
1769
+ # Phase 2: record this run's surfacings. A topic whose (possibly
1770
+ # fuzzy) prior row is covered inherits that covered state, so a
1771
+ # user's covered mark survives judge naming drift instead of
1772
+ # silently forking into a fresh uncovered row.
1773
+ for topic, prior in zip(report.topics, priors):
1774
+ inherit_covered_at = None
1775
+ if prior and prior["status"] == "covered":
1776
+ inherit_covered_at = prior["covered_at"] or prior["last_surfaced"]
1777
+ store.record_discovery_surfacing(
1778
+ topic.name,
1779
+ domain=report.domain,
1780
+ run_ref=run_ref,
1781
+ as_of=as_of,
1782
+ inherit_covered_at=inherit_covered_at,
1783
+ )
1784
+ for topic, prior in zip(report.topics, priors):
1785
+ if prior:
1786
+ topic = dataclasses.replace(
1787
+ topic,
1788
+ previously_surfaced_count=prior["surface_count"],
1789
+ last_surfaced=prior["last_surfaced"],
1790
+ covered=prior["status"] == "covered",
1791
+ )
1792
+ annotated.append(topic)
1793
+ return dataclasses.replace(report, topics=annotated)
1794
+
1795
+
1796
+ def _record_discovery_queue_safely(
1797
+ report: schema.DiscoveryReport,
1798
+ args: argparse.Namespace,
1799
+ config: dict[str, object],
1800
+ run_ref: str | None = None,
1801
+ ) -> schema.DiscoveryReport:
1802
+ """Annotate + record the discovery queue, degrading a broken research.db
1803
+ (locked, read-only dir, corrupt) to a stderr warning: a queue failure
1804
+ must never destroy a finished pipeline run or the protocol's final
1805
+ brief. Shared verbatim by the one-shot and finalize paths."""
1806
+ try:
1807
+ return _annotate_and_record_discovery_queue(
1808
+ report, args, config, run_ref=run_ref,
1809
+ )
1810
+ except (sqlite3.Error, OSError) as exc:
1811
+ sys.stderr.write(
1812
+ f"[last30days] Warning: discovery queue unavailable ({exc}); "
1813
+ "continuing without queue annotations.\n"
1814
+ )
1815
+ return report
1816
+
1817
+
1818
+ def _emit_and_save_discovery_report(
1819
+ report: schema.DiscoveryReport,
1820
+ args: argparse.Namespace,
1821
+ domain: str,
1822
+ ) -> None:
1823
+ """Render a discovery report per --emit, honor --output/--save-dir, and
1824
+ print it. Shared verbatim by the one-shot and finalize paths."""
1825
+ if args.emit == "json":
1826
+ payload = schema.to_dict(report) if args.json_profile == "raw" else schema.to_discovery_export(report)
1827
+ rendered = json.dumps(payload, indent=2, sort_keys=True)
1828
+ else:
1829
+ rendered = render.render_discovery(report)
1830
+
1831
+ if args.output:
1832
+ output_path = save_rendered_output(rendered, args.output)
1833
+ sys.stderr.write(f"[last30days] Saved output to {output_path}\n")
1834
+ if args.save_dir:
1835
+ save_path = _save_discovery_output(
1836
+ rendered,
1837
+ domain=domain or "trending",
1838
+ emit=args.emit,
1839
+ save_dir=args.save_dir,
1840
+ suffix=args.save_suffix or "",
1841
+ )
1842
+ sys.stderr.write(f"[last30days] Saved output to {save_path}\n")
1843
+ print(rendered)
1844
+
1845
+
1846
+ def _discovery_strict_exit_code(
1847
+ source_status: dict[str, schema.SourceOutcome],
1848
+ config: dict[str, object],
1849
+ ) -> int:
1850
+ """The ONE LAST30DAYS_STRICT_EXIT evaluation for every discovery
1851
+ invocation - the one-shot and all three protocol legs (issue #384's
1852
+ discovery counterpart). Rendering/output already happened by the time
1853
+ this runs; only the exit code shifts to 3 when strict exit is on and any
1854
+ source outcome is neither clean nor an expected skip."""
1855
+ strict = str(config.get("LAST30DAYS_STRICT_EXIT") or "").strip().lower()
1856
+ if strict not in {"1", "true", "yes", "on"}:
1857
+ return 0
1858
+ degraded = sorted(
1859
+ source for source, outcome in (source_status or {}).items()
1860
+ if outcome.state not in _STRICT_EXIT_OK_STATES
1861
+ )
1862
+ if not degraded:
1863
+ return 0
1864
+ sys.stderr.write(
1865
+ f"[last30days] strict-exit: degraded sources: {', '.join(degraded)}\n"
1866
+ )
1867
+ sys.stderr.flush()
1868
+ return 3
1869
+
1870
+
1871
+ def _require_discover_mock_parity(
1872
+ loaded_mock: bool,
1873
+ args_mock: bool,
1874
+ *,
1875
+ label: str,
1876
+ path: Path | None,
1877
+ ) -> None:
1878
+ """A protocol leg's --mock flag must match the loaded handoff file's
1879
+ stamped provenance: mock-born state finalized by a real run would fake a
1880
+ real brief from fixture data, and real state finalized by --mock would
1881
+ silently drop the round's queue write. Mismatch is a contract failure
1882
+ (exit 2 via HandoffContractError)."""
1883
+ if bool(loaded_mock) == bool(args_mock):
1884
+ return
1885
+ location = str(path) if path is not None else "(unknown path)"
1886
+ if loaded_mock:
1887
+ raise discovery_handoff.HandoffContractError(
1888
+ f"{label} {location} is mock-born (a --mock leg wrote it): "
1889
+ "mock-born state cannot be finalized by a real run. Re-run this "
1890
+ "leg with --mock, or start a fresh real `--discover "
1891
+ "--nominate-only` sweep."
1892
+ )
1893
+ raise discovery_handoff.HandoffContractError(
1894
+ f"{label} {location} was written by a real run: real state "
1895
+ "cannot be finalized by a --mock run. Drop --mock, or start a fresh "
1896
+ "`--discover --nominate-only --mock` sweep."
1897
+ )
1898
+
1899
+
1900
+ def _run_queue_list(args: argparse.Namespace, config: dict[str, object]) -> int:
1901
+ """List uncovered surfaced topics from the persistent discovery queue."""
1902
+ import store
1903
+
1904
+ db_path = _scoped_store_db(args)
1905
+ if not Path(db_path or store.DB_PATH).exists():
1906
+ print("Discovery queue is empty - no discovery run has recorded topics yet.")
1907
+ return 0
1908
+ with store.scoped_db(db_path):
1909
+ rows = store.list_discovery_queue(status="surfaced")
1910
+ if not rows:
1911
+ # An existing db with zero queue rows (e.g. created via --store)
1912
+ # means no discovery run has recorded anything - only claim
1913
+ # "every topic is covered" when covered rows actually exist.
1914
+ if store.list_discovery_queue():
1915
+ print("Discovery queue is empty - every surfaced topic is marked covered.")
1916
+ else:
1917
+ print("Discovery queue is empty - no discovery run has recorded topics yet.")
1918
+ return 0
1919
+
1920
+ headers = ("name", "domain", "surface_count", "last_surfaced", "status")
1921
+ table = [
1922
+ (
1923
+ str(row["name"]),
1924
+ str(row["domain"] or "-"),
1925
+ str(row["surface_count"]),
1926
+ str(row["last_surfaced"]),
1927
+ str(row["status"]),
1928
+ )
1929
+ for row in rows
1930
+ ]
1931
+ widths = [
1932
+ max(len(headers[column]), *(len(row[column]) for row in table))
1933
+ for column in range(len(headers))
1934
+ ]
1935
+ lines = [
1936
+ " ".join(header.ljust(widths[i]) for i, header in enumerate(headers)).rstrip(),
1937
+ " ".join("-" * widths[i] for i in range(len(headers))),
1938
+ ]
1939
+ lines.extend(
1940
+ " ".join(row[i].ljust(widths[i]) for i in range(len(headers))).rstrip()
1941
+ for row in table
1942
+ )
1943
+ print("\n".join(lines))
1944
+ return 0
1945
+
1946
+
1947
+ def _run_queue_cover(
1948
+ args: argparse.Namespace,
1949
+ config: dict[str, object],
1950
+ name: str,
1951
+ ) -> int:
1952
+ """Mark a queued discovery topic covered; unknown names error loudly."""
1953
+ import store
1954
+
1955
+ if not name:
1956
+ sys.stderr.write(
1957
+ "[last30days] queue cover requires a topic name: "
1958
+ 'queue cover "<topic name>".\n'
1959
+ )
1960
+ return 2
1961
+ db_path = _scoped_store_db(args)
1962
+ if not Path(db_path or store.DB_PATH).exists():
1963
+ sys.stderr.write(
1964
+ f"[last30days] No queued topic named {name!r}: the discovery queue "
1965
+ "is empty (no discovery run has recorded topics yet).\n"
1966
+ )
1967
+ return 2
1968
+ with store.scoped_db(db_path):
1969
+ row = store.mark_discovery_covered(
1970
+ name, as_of=datetime.date.today().isoformat()
1971
+ )
1972
+ if row is None:
1973
+ sys.stderr.write(
1974
+ f"[last30days] No queued topic named {name!r}. Covering requires "
1975
+ "the exact topic name; run 'queue list' to see queued names.\n"
1976
+ )
1977
+ return 2
1978
+ print(f"Marked covered: {row['name']} (covered {row['covered_at']})")
1979
+ return 0
1980
+
1981
+
1982
+ def _resolve_discovery_source_boundary(
1983
+ args: argparse.Namespace, config: dict[str, object],
1984
+ ) -> tuple[list[str] | None, list[str] | None] | None:
1985
+ """Resolve the discovery sweep's source lists from the user's boundary.
1986
+
1987
+ Returns ``(listing_sources, enrichment_boundary)`` - the discovery-capable
1988
+ subset for the sweep, and the user's ORIGINAL boundary honored by the
1989
+ per-topic research passes (which reach beyond the listing feeds - e.g.
1990
+ Techmeme, arXiv, YouTube, Polymarket); both None mean every available
1991
+ source. Returns None (after writing the exit-2 error) when the configured
1992
+ boundary leaves nothing to sweep: silently widening to all feeds would
1993
+ query sources the user filtered out.
1994
+ """
1995
+ requested_sources = resolve_requested_sources(args.search, config)
1996
+ enrich_requested_sources = list(requested_sources) if requested_sources else None
1997
+ if requested_sources:
1998
+ discovery_sources = [
1999
+ source for source in requested_sources
2000
+ if source in pipeline.DISCOVERY_SOURCES
2001
+ ]
2002
+ if not discovery_sources:
2003
+ origin = "--search" if args.search is not None else "LAST30DAYS_DEFAULT_SEARCH"
2004
+ sys.stderr.write(
2005
+ f"[last30days] {origin} has no discovery-capable sources "
2006
+ f"(unsupported: {', '.join(requested_sources)}); discovery "
2007
+ f"sweeps use: {', '.join(pipeline.DISCOVERY_SOURCES)}. Pass "
2008
+ "--search with one of those (or clear the source filter) to "
2009
+ "run a sweep.\n"
2010
+ )
2011
+ return None
2012
+ requested_sources = discovery_sources
2013
+ return requested_sources, enrich_requested_sources
2014
+
2015
+
2016
+ def _discover_subreddits(args: argparse.Namespace) -> list[str] | None:
2017
+ return (
2018
+ [value.strip().removeprefix("r/") for value in args.subreddits.split(",") if value.strip()]
2019
+ if args.subreddits else None
2020
+ )
2021
+
2022
+
2023
+ def _discover_domain(args: argparse.Namespace) -> str:
2024
+ """The whitespace-normalized discovery domain; empty = global trending."""
2025
+ return " ".join(str(args.discover or "").split())
2026
+
2027
+
2028
+ def _run_discover(args: argparse.Namespace, config: dict[str, object]) -> int:
2029
+ domain = _discover_domain(args)
2030
+ # Empty domain = global trending: sweep every river feed's hot list with no
2031
+ # keyword gate. The confidence floor is what keeps junk out, not a keyword.
2032
+ # (--as-of and HTML rejection live in _main's shared --discover dispatch,
2033
+ # so every leg - one-shot or protocol - applies the same guards.)
2034
+ if args.synthesis_file:
2035
+ sys.stderr.write("[last30days] Warning: --synthesis-file is not used by discovery mode.\n")
2036
+
2037
+ boundary = _resolve_discovery_source_boundary(args, config)
2038
+ if boundary is None:
2039
+ return 2
2040
+ requested_sources, enrich_requested_sources = boundary
2041
+ subreddits = _discover_subreddits(args)
2042
+ depth = "deep" if args.deep else "quick" if args.quick else "default"
2043
+ try:
2044
+ report = pipeline.run_discover(
2045
+ domain=domain,
2046
+ config=config,
2047
+ depth=depth,
2048
+ requested_sources=requested_sources,
2049
+ mock=args.mock,
2050
+ subreddits=subreddits,
2051
+ lookback_days=args.lookback_days or 30,
2052
+ as_of_date=args.as_of_date,
2053
+ enrich=not args.discover_shallow,
2054
+ enrich_requested_sources=enrich_requested_sources,
2055
+ )
2056
+ except ValueError as exc:
2057
+ sys.stderr.write(f"[last30days] {exc}\n")
2058
+ return 2
2059
+
2060
+ # Persistent topic queue: annotate this report from prior surfacings, then
2061
+ # record this run's surfacings - BEFORE rendering/export so the Pipeline
2062
+ # line and the JSON queue fields see the annotations. Mock runs stay 100%
2063
+ # side-effect-free.
2064
+ if not args.mock:
2065
+ report = _record_discovery_queue_safely(report, args, config)
2066
+
2067
+ _emit_and_save_discovery_report(report, args, domain)
2068
+ return _discovery_strict_exit_code(report.source_status, config)
2069
+
2070
+
2071
+ def _discover_handoff_state_dir(args: argparse.Namespace) -> Path | None:
2072
+ """One resolver for every protocol leg's handoff files: the save dir when
2073
+ given (mirroring _scoped_store_db's scoping), else the config dir - the
2074
+ same base _last_report_cache_path uses. args.save_dir is read AFTER the
2075
+ LAST30DAYS_MEMORY_DIR fallback in _main resolved it."""
2076
+ return discovery_handoff.handoff_state_dir(
2077
+ getattr(args, "save_dir", None), env.CONFIG_DIR
2078
+ )
2079
+
2080
+
2081
+ def _run_discover_nominate(args: argparse.Namespace, config: dict[str, object]) -> int:
2082
+ """Protocol leg 1: sweep the listings, build the full judge pool, write
2083
+ the nominations bundle, and print the host-facing judging digest.
2084
+
2085
+ No stage-1 judge, enrichment, confidence floor, or queue writes happen on
2086
+ this leg - the host judges from the bundle and leg 2 (--judgments)
2087
+ resumes from it. A zero-nomination sweep short-circuits to the existing
2088
+ nothing-solid brief with NO bundle written: there is nothing to judge.
2089
+ Writing a fresh bundle starts a NEW protocol round, so any pending
2090
+ report left by a prior round is deleted alongside it.
2091
+ """
2092
+ domain = _discover_domain(args)
2093
+ boundary = _resolve_discovery_source_boundary(args, config)
2094
+ if boundary is None:
2095
+ return 2
2096
+ requested_sources, enrich_requested_sources = boundary
2097
+ lookback_days = args.lookback_days or 30
2098
+ try:
2099
+ result = pipeline.run_discover_nominate(
2100
+ domain=domain,
2101
+ config=config,
2102
+ depth="deep" if args.deep else "quick" if args.quick else "default",
2103
+ requested_sources=requested_sources,
2104
+ mock=args.mock,
2105
+ subreddits=_discover_subreddits(args),
2106
+ lookback_days=lookback_days,
2107
+ as_of_date=args.as_of_date,
2108
+ )
2109
+ except ValueError as exc:
2110
+ sys.stderr.write(f"[last30days] {exc}\n")
2111
+ return 2
2112
+
2113
+ if not result.pool:
2114
+ print(render.render_discovery(pipeline.nominate_nothing_solid_report(result)))
2115
+ return _discovery_strict_exit_code(result.source_status, config)
2116
+
2117
+ entries = [
2118
+ discovery_handoff.PoolEntry(
2119
+ nomination=nomination,
2120
+ cluster_id=cluster_id,
2121
+ # No provider runs on this leg, so the nomination's name and junk
2122
+ # flag ARE the topic_shape heuristics - stored on the row as
2123
+ # leg 2's fallback for anything the host leaves unjudged.
2124
+ heuristic_name=nomination.name,
2125
+ heuristic_junk=nomination.junk_shape,
2126
+ )
2127
+ for nomination, cluster_id in result.pool
2128
+ ]
2129
+ bundle = discovery_handoff.write_nominations_bundle(
2130
+ entries,
2131
+ domain=result.plan.domain,
2132
+ tier="shallow" if args.discover_shallow else "deep",
2133
+ from_date=result.from_date,
2134
+ to_date=result.to_date,
2135
+ lookback_days=lookback_days,
2136
+ enrichment_source_boundary=enrich_requested_sources,
2137
+ requested_sources=requested_sources,
2138
+ # The sweep's finalized per-source outcomes ride the bundle so legs
2139
+ # 2-3 report degraded coverage instead of silently reading clean; the
2140
+ # mock stamp keeps mock-born and real state from cross-finalizing.
2141
+ source_status=result.source_status,
2142
+ warnings=result.warnings,
2143
+ mock=args.mock,
2144
+ # Same resolution as _discover_handoff_state_dir: save dir when
2145
+ # given, else the config dir.
2146
+ save_dir=getattr(args, "save_dir", None),
2147
+ config_dir=env.CONFIG_DIR,
2148
+ )
2149
+ # A fresh bundle starts a NEW protocol round: a pending report left by a
2150
+ # prior round is cross-round state a bare --finalize could silently
2151
+ # consume - delete it (missing file is a no-op).
2152
+ state_dir = _discover_handoff_state_dir(args)
2153
+ if state_dir is not None:
2154
+ discovery_handoff.pending_report_path(state_dir).unlink(missing_ok=True)
2155
+ print(discovery_handoff.build_host_digest(bundle))
2156
+ print(
2157
+ "\nJudgments file schema (leg 2): "
2158
+ f'{{"bundle_id": "{bundle.bundle_id}", "judgments": '
2159
+ '[{"id": "n1", "name": "<short topic name>", "junk": false, '
2160
+ '"worthiness": 0-100}, ...]}. '
2161
+ "Then resume with: --discover --judgments <path>."
2162
+ )
2163
+ return _discovery_strict_exit_code(result.source_status, config)
2164
+
2165
+
2166
+ def _run_discover_resume(args: argparse.Namespace, config: dict[str, object]) -> int:
2167
+ """Protocol leg 2: resume from the nominations bundle, apply the host
2168
+ judgments file, run the deep per-topic research pass, and persist the
2169
+ ranked result as the pending report for leg 3 (--finalize).
2170
+
2171
+ Contract failures (missing/stale bundle, judgments not bound to it, a
2172
+ bundle whose mock provenance disagrees with this run's --mock flag, an
2173
+ unwritable pending-report path) raise HandoffContractError and map to
2174
+ exit 2 in _run_discover_protocol_leg. Zero floor survivors renders the
2175
+ nothing-solid brief right here (clearing any stale prior-round pending
2176
+ file): no pending file, no leg 3. No queue writes and no artifact saves
2177
+ happen on this leg - the topic queue and the rendered brief belong to
2178
+ leg 3.
2179
+ """
2180
+ save_dir = getattr(args, "save_dir", None)
2181
+ bundle = discovery_handoff.read_nominations_bundle(
2182
+ save_dir=save_dir, config_dir=env.CONFIG_DIR,
2183
+ )
2184
+ _require_discover_mock_parity(
2185
+ bundle.mock, args.mock,
2186
+ label="Nominations bundle", path=bundle.path,
2187
+ )
2188
+ judgments = discovery_handoff.read_judgments(
2189
+ args.judgments, bundle, save_dir=save_dir, config_dir=env.CONFIG_DIR,
2190
+ )
2191
+ result = pipeline.run_discover_resume(
2192
+ bundle, judgments, config=config, mock=args.mock,
2193
+ )
2194
+ report = result.report
2195
+
2196
+ if not report.topics:
2197
+ # Nothing cleared the floor: the honest brief ends the protocol here.
2198
+ # This round wrote no pending file, so a stale one from an earlier
2199
+ # round must not survive to feed a bare --finalize (missing file is
2200
+ # a no-op).
2201
+ state_dir = _discover_handoff_state_dir(args)
2202
+ if state_dir is not None:
2203
+ discovery_handoff.pending_report_path(state_dir).unlink(missing_ok=True)
2204
+ print(render.render_discovery(report))
2205
+ return _discovery_strict_exit_code(report.source_status, config)
2206
+
2207
+ state_dir = _discover_handoff_state_dir(args)
2208
+ if state_dir is None:
2209
+ # Unreachable in practice - reading the bundle above required one of
2210
+ # these locations - but kept as a loud contract error, not an assert.
2211
+ raise discovery_handoff.HandoffContractError(
2212
+ "No handoff location available to write the pending report: "
2213
+ "pass --save-dir or configure ~/.config/last30days/."
2214
+ )
2215
+ pending_path = discovery_handoff.pending_report_path(state_dir)
2216
+ payload = {
2217
+ "kind": schema.DISCOVERY_PENDING_KIND,
2218
+ "schema_version": schema.DISCOVERY_PENDING_SCHEMA_VERSION,
2219
+ "bundle_id": bundle.bundle_id,
2220
+ # Fresh TTL clock: leg 3 measures staleness from THIS resume run,
2221
+ # not from the leg-1 sweep.
2222
+ "generated_at": report.generated_at,
2223
+ # Same run_ref format the queue records (leg 3 replays it verbatim).
2224
+ "run_ref": f"discover:{report.domain or 'trending'}:{report.generated_at}",
2225
+ # Leg-2 provenance: leg 3 refuses to finalize across the mock/real
2226
+ # boundary in either direction.
2227
+ "mock": bool(args.mock),
2228
+ # Full schema round-trip (the _write_last_run precedent): leg 3
2229
+ # rebuilds the report from this dict instead of re-running anything.
2230
+ "report": schema.to_dict(report),
2231
+ "angle_inputs": result.angle_inputs,
2232
+ }
2233
+ # ONE post-loop write from the main thread; enrichment workers are daemon
2234
+ # threads and never touch disk.
2235
+ try:
2236
+ state_dir.mkdir(parents=True, exist_ok=True)
2237
+ pending_path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
2238
+ except OSError as exc:
2239
+ # A locked/read-only/full disk is the protocol's clean exit-2 path,
2240
+ # never a traceback (same contract as the bundle write).
2241
+ raise discovery_handoff.HandoffContractError(
2242
+ f"Could not write pending discovery report {pending_path}: {exc}"
2243
+ ) from exc
2244
+
2245
+ print(
2246
+ f"Judged discovery resume: {len(report.topics)} topic"
2247
+ f"{'s' if len(report.topics) != 1 else ''} cleared the floor "
2248
+ f"(bundle_id {bundle.bundle_id})."
2249
+ )
2250
+ print(f"Pending report: {pending_path}")
2251
+ print("\nAngle inputs by nomination id:")
2252
+ print(json.dumps(result.angle_inputs, indent=2))
2253
+ print(
2254
+ "\nWrite the angles file (leg 3): "
2255
+ f'{{"bundle_id": "{bundle.bundle_id}", "angles": '
2256
+ '[{"id": "n1", "podcast": "<one-sentence hook>", '
2257
+ '"x_article": "<one-sentence hook>"}, ...]} - one row per topic id '
2258
+ "above.\n"
2259
+ "Then finalize with: --discover --finalize --angles <path>."
2260
+ )
2261
+ return _discovery_strict_exit_code(report.source_status, config)
2262
+
2263
+
2264
+ def _run_discover_finalize(args: argparse.Namespace, config: dict[str, object]) -> int:
2265
+ """Protocol leg 3: load the leg-2 pending report, apply host angles,
2266
+ render the final brief, save discovery artifacts, and record the topic
2267
+ queue. The cheap offline leg - no sweep, no enrichment, no providers,
2268
+ no network; everything renders from the pending report. (HTML/--as-of
2269
+ rejection lives in _main's shared --discover dispatch.)
2270
+
2271
+ Contract failures (missing/stale/mismatched pending report or angles)
2272
+ raise HandoffContractError and map to exit 2 in
2273
+ _run_discover_protocol_leg. The pending file is deliberately LEFT IN
2274
+ PLACE on success: a finalize retry with a corrected angles file must
2275
+ keep working within the TTL, and the queue records under the pending
2276
+ report's leg-2 run_ref, so retries never double-count a surfacing.
2277
+ Mock finalize renders identically but writes no queue rows.
2278
+ """
2279
+ import dataclasses
2280
+
2281
+ save_dir = getattr(args, "save_dir", None)
2282
+ pending = discovery_handoff.read_pending_report(
2283
+ save_dir=save_dir, config_dir=env.CONFIG_DIR,
2284
+ )
2285
+ _require_discover_mock_parity(
2286
+ pending.mock, args.mock,
2287
+ label="Pending discovery report", path=pending.path,
2288
+ )
2289
+ angles = discovery_handoff.read_angles(
2290
+ args.angles, pending, save_dir=save_dir, config_dir=env.CONFIG_DIR,
2291
+ )
2292
+ try:
2293
+ report = schema.discovery_report_from_dict(pending.report)
2294
+ except (KeyError, TypeError, ValueError) as exc:
2295
+ # The envelope validated but the report body is structurally
2296
+ # incomplete: a contract failure with the resume remedy, never a
2297
+ # traceback out of the finalize leg.
2298
+ raise discovery_handoff.HandoffContractError(
2299
+ f"Pending discovery report {pending.path} carries a malformed "
2300
+ f"report body ({type(exc).__name__}: {exc}). "
2301
+ f"{discovery_handoff._RESUME_REMEDY}"
2302
+ ) from exc
2303
+
2304
+ if angles:
2305
+ # Host angles are keyed by nomination id; the pending report's
2306
+ # angle_inputs mapping carries each surviving id's applied topic
2307
+ # name, which is how angles land on the right DiscoveryTopic.
2308
+ angles_by_name = {
2309
+ name: host
2310
+ for nomination_id, host in angles.items()
2311
+ if (name := (pending.angle_inputs.get(nomination_id) or {}).get("name"))
2312
+ }
2313
+ report = dataclasses.replace(report, topics=[
2314
+ dataclasses.replace(
2315
+ topic,
2316
+ podcast_angle=host.podcast,
2317
+ x_article_angle=host.x_article,
2318
+ )
2319
+ if (host := angles_by_name.get(topic.name)) is not None
2320
+ else topic
2321
+ for topic in report.topics
2322
+ ])
2323
+
2324
+ # Persistent topic queue: the protocol's ONE queue write happens here,
2325
+ # under the leg-2 run identity (pending.run_ref) so finalize retries are
2326
+ # idempotent. Mock runs stay 100% side-effect-free.
2327
+ if not args.mock:
2328
+ report = _record_discovery_queue_safely(
2329
+ report, args, config, run_ref=pending.run_ref or None,
2330
+ )
2331
+
2332
+ _emit_and_save_discovery_report(report, args, report.domain)
2333
+ return _discovery_strict_exit_code(report.source_status, config)
2334
+
2335
+
2336
+ def _run_discover_protocol_leg(
2337
+ args: argparse.Namespace, config: dict[str, object]
2338
+ ) -> int:
2339
+ """Route one validated protocol invocation to its leg. Contract failures
2340
+ (unreadable/stale/mismatched handoff files) map to stderr + exit 2 here,
2341
+ so the leg bodies (U3-U5) raise HandoffContractError freely."""
2342
+ try:
2343
+ if args.nominate_only:
2344
+ return _run_discover_nominate(args, config)
2345
+ # --judgments dispatch keys on flag presence (is not None), matching
2346
+ # the --discover convention: never on the path string's truthiness.
2347
+ if args.judgments is not None:
2348
+ return _run_discover_resume(args, config)
2349
+ return _run_discover_finalize(args, config)
2350
+ except discovery_handoff.HandoffContractError as exc:
2351
+ sys.stderr.write(f"[last30days] {exc.message}\n")
2352
+ return 2
2353
+
2354
+
2355
+ _STRICT_EXIT_OK_STATES = {"ok", "no-results", "skipped-unconfigured"}
2356
+
2357
+
2358
+ def _strict_exit_code(
2359
+ report: schema.Report,
2360
+ entity_reports: list[tuple[str, schema.Report]] | None,
2361
+ config: dict[str, object],
2362
+ ) -> int:
2363
+ """Opt-in machine-detectable degraded-run signal (issue #384).
2364
+
2365
+ When LAST30DAYS_STRICT_EXIT is truthy, a run whose report carries any
2366
+ source outcome that is neither clean nor a plain no-results exits 3 so
2367
+ cron/CI wrappers can distinguish degraded coverage from success. Default
2368
+ behavior (exit 0, warning rendered in the report footer) is unchanged.
2369
+ """
2370
+ raw = str(config.get("LAST30DAYS_STRICT_EXIT") or "").strip().lower()
2371
+ if raw not in {"1", "true", "yes", "on"}:
2372
+ return 0
2373
+ reports = [report] + [rep for _, rep in (entity_reports or [])]
2374
+ degraded = sorted({
2375
+ name
2376
+ for rep in reports
2377
+ for name, outcome in (rep.source_status or {}).items()
2378
+ if outcome.state not in _STRICT_EXIT_OK_STATES
2379
+ })
2380
+ if not degraded:
2381
+ return 0
2382
+ sys.stderr.write(
2383
+ f"[last30days] strict-exit: degraded sources: {', '.join(degraded)}\n"
2384
+ )
2385
+ sys.stderr.flush()
2386
+ return 3
2387
+
2388
+
2389
+ def _audience_register_for_run(
2390
+ args: argparse.Namespace,
2391
+ config: dict[str, object],
2392
+ entity_reports: list[tuple[str, schema.Report]] | None,
2393
+ ) -> registers.AudienceRegister:
2394
+ """Resolve CLI > config for single-topic standard brief renderers."""
2395
+
2396
+ from lib import planner
2397
+
2398
+ topic = " ".join(getattr(args, "topic", [])).strip()
2399
+ comparison_topic_requested = bool(
2400
+ len(planner._comparison_entities(topic)) >= 2
2401
+ or args.competitors is not None
2402
+ or args.competitors_list
2403
+ or args.competitors_plan
2404
+ )
2405
+ if (
2406
+ entity_reports
2407
+ or comparison_topic_requested
2408
+ or args.drill
2409
+ or args.emit not in {"compact", "md", "html"}
2410
+ ):
2411
+ return registers.get_register()
2412
+ explicit = getattr(args, "register", None)
2413
+ configured = config.get("LAST30DAYS_REGISTER")
2414
+ name = explicit or (str(configured) if configured else "default")
2415
+ # Preserve configs written by the pre-register ELI5 follow-up command.
2416
+ legacy_eli5 = str(config.get("ELI5_MODE") or "").strip().lower()
2417
+ if not explicit and not configured and legacy_eli5 in {"1", "true", "yes", "on"}:
2418
+ name = "eli5"
2419
+ return registers.get_register(name)
2420
+
2421
+
2422
+ def _render_save_and_print(
2423
+ args: argparse.Namespace,
2424
+ report: schema.Report,
2425
+ entity_reports: list[tuple[str, schema.Report]] | None,
2426
+ synthesis_md: str | None,
2427
+ config: dict[str, object],
2428
+ ) -> int:
2429
+ fun_level = str(config.get("FUN_LEVEL", "medium")).lower()
2430
+ try:
2431
+ audience = _audience_register_for_run(args, config, entity_reports)
2432
+ except ValueError as exc:
2433
+ sys.stderr.write(f"[last30days] {exc}\n")
2434
+ return 2
2435
+ if audience.name != "default":
2436
+ sys.stderr.write(f"[last30days] Audience register: {audience.name}\n")
2437
+ sys.stderr.flush()
2438
+ # Comparison HTML is the one case where the saved file's title and content
2439
+ # have to be overridden away from the leading entity's report. Compute the
2440
+ # gate once so the footer-display and save-output paths can't disagree.
2441
+ is_comparison_html = bool(entity_reports) and args.emit == "html"
2442
+ footer_save_path = None
2443
+ if args.output:
2444
+ footer_save_path = compute_output_path_display(args.output)
2445
+ elif args.save_dir:
2446
+ save_topic_for_display = comparison_topic(entity_reports) if is_comparison_html else report.topic
2447
+ footer_save_path = compute_save_path_display(
2448
+ args.save_dir, save_topic_for_display, args.save_suffix or "", args.emit
2449
+ )
784
2450
 
785
2451
  if entity_reports:
786
2452
  rendered = emit_comparison_output(
@@ -789,6 +2455,7 @@ def _render_save_and_print(
789
2455
  fun_level=fun_level,
790
2456
  save_path=footer_save_path,
791
2457
  synthesis_md=synthesis_md,
2458
+ json_profile=args.json_profile,
792
2459
  )
793
2460
  else:
794
2461
  rendered = emit_output(
@@ -797,16 +2464,62 @@ def _render_save_and_print(
797
2464
  fun_level=fun_level,
798
2465
  save_path=footer_save_path,
799
2466
  synthesis_md=synthesis_md,
2467
+ json_profile=args.json_profile,
2468
+ register=audience.name,
800
2469
  )
2470
+ has_private_corpus = _report_has_private_corpus(report) or bool(
2471
+ entity_reports
2472
+ and any(_report_has_private_corpus(entity) for _label, entity in entity_reports)
2473
+ )
2474
+ private_saved_format = has_private_corpus
801
2475
  publish_companion_paths: list[Path] = []
802
2476
  if args.output:
803
- output_path = save_rendered_output(rendered, args.output)
2477
+ output_path = save_rendered_output(
2478
+ rendered,
2479
+ args.output,
2480
+ private=private_saved_format,
2481
+ )
804
2482
  if args.emit == "html":
805
2483
  publish_companion_paths.append(output_path)
806
2484
  sys.stderr.write(f"[last30days] Saved output to {output_path}\n")
807
2485
  sys.stderr.flush()
808
2486
  if args.save_dir:
809
2487
  # Save the main topic's raw file (single-entity or comparison main).
2488
+ # Bind the render to the path save_output actually allocates so the
2489
+ # saved report and stdout agree even when collision fallback is used.
2490
+ def _render_with_actual_path(actual_path: Path) -> str:
2491
+ nonlocal rendered
2492
+ display = compute_output_path_display(str(actual_path))
2493
+ if entity_reports:
2494
+ rendered = emit_comparison_output(
2495
+ entity_reports,
2496
+ args.emit,
2497
+ fun_level=fun_level,
2498
+ save_path=display,
2499
+ synthesis_md=synthesis_md,
2500
+ json_profile=args.json_profile,
2501
+ )
2502
+ else:
2503
+ rendered = emit_output(
2504
+ report,
2505
+ args.emit,
2506
+ fun_level=fun_level,
2507
+ save_path=display,
2508
+ synthesis_md=synthesis_md,
2509
+ json_profile=args.json_profile,
2510
+ register=audience.name,
2511
+ )
2512
+ if args.emit not in {"json", "html"} and not entity_reports:
2513
+ # Markdown saves keep the complete debug artifact (all clusters
2514
+ # and per-source items), matching the render_fn-less path in
2515
+ # save_output and the comparison peer saves. Saving the compact
2516
+ # stdout render instead made most collected evidence
2517
+ # unrecoverable from the raw file (#923). The stdout re-render
2518
+ # above still runs so the visible footer cites the real path,
2519
+ # and the saved artifact carries the same citation.
2520
+ return render.render_full(report, save_path=display)
2521
+ return rendered
2522
+
810
2523
  save_path = save_output(
811
2524
  report,
812
2525
  args.emit,
@@ -814,7 +2527,10 @@ def _render_save_and_print(
814
2527
  suffix=args.save_suffix or "",
815
2528
  synthesis_md=synthesis_md,
816
2529
  topic_override=comparison_topic(entity_reports) if is_comparison_html else None,
817
- rendered_content=rendered if is_comparison_html else None,
2530
+ json_profile=args.json_profile,
2531
+ register=audience.name,
2532
+ private=private_saved_format,
2533
+ render_fn=_render_with_actual_path,
818
2534
  )
819
2535
  if args.emit == "html":
820
2536
  publish_companion_paths.append(save_path)
@@ -828,6 +2544,8 @@ def _render_save_and_print(
828
2544
  entity_report, args.emit, args.save_dir,
829
2545
  suffix=args.save_suffix or "",
830
2546
  synthesis_md=synthesis_md,
2547
+ json_profile=args.json_profile,
2548
+ private=_report_has_private_corpus(entity_report),
831
2549
  )
832
2550
  comparison_peer_paths.append(peer_path)
833
2551
  sys.stderr.write(f"[last30days] Saved output to {peer_path}\n")
@@ -839,9 +2557,40 @@ def _render_save_and_print(
839
2557
  sys.stderr.flush()
840
2558
  if args.publish_html:
841
2559
  try:
2560
+ has_private_corpus = "corpus" in report.source_status or bool(
2561
+ entity_reports
2562
+ and any("corpus" in entity.source_status for _label, entity in entity_reports)
2563
+ )
2564
+ publish_rendered = rendered
2565
+ if has_private_corpus:
2566
+ sys.stderr.write(
2567
+ "[last30days] Excluding local corpus evidence and synthesis from published HTML.\n"
2568
+ )
2569
+ if entity_reports:
2570
+ publish_rendered = emit_comparison_output(
2571
+ [
2572
+ (label, schema.without_sources(entity, {"corpus"}))
2573
+ for label, entity in entity_reports
2574
+ ],
2575
+ "html",
2576
+ fun_level=fun_level,
2577
+ save_path=footer_save_path,
2578
+ synthesis_md=None,
2579
+ json_profile=args.json_profile,
2580
+ )
2581
+ else:
2582
+ publish_rendered = emit_output(
2583
+ schema.without_sources(report, {"corpus"}),
2584
+ "html",
2585
+ fun_level=fun_level,
2586
+ save_path=footer_save_path,
2587
+ synthesis_md=None,
2588
+ json_profile=args.json_profile,
2589
+ register=audience.name,
2590
+ )
842
2591
  publish_result = publish_rendered_html(
843
- rendered,
844
- password=_publish_password_for_args(args),
2592
+ publish_rendered,
2593
+ password=_publish_password_for_args(args, config),
845
2594
  companion_paths=publish_companion_paths,
846
2595
  )
847
2596
  sys.stderr.write(f"[last30days] Published HTML to {publish_result['url']}\n")
@@ -857,7 +2606,7 @@ def _render_save_and_print(
857
2606
  sys.stderr.write(f"[last30days] HTML publish failed: {exc}\n")
858
2607
  sys.stderr.flush()
859
2608
  print(rendered)
860
- return 0
2609
+ return _strict_exit_code(report, entity_reports, config)
861
2610
 
862
2611
 
863
2612
  def _propagate_config_to_environ(config: dict[str, object]) -> None:
@@ -868,7 +2617,7 @@ def _propagate_config_to_environ(config: dict[str, object]) -> None:
868
2617
  XAI_BASE_URL overrides are silently ignored. This is a no-op for
869
2618
  keys that are already set in process env.
870
2619
  """
871
- for key in ("OPENAI_BASE_URL", "XAI_BASE_URL"):
2620
+ for key in ("OPENAI_BASE_URL", "XAI_BASE_URL", "OPENROUTER_BASE_URL"):
872
2621
  val = config.get(key)
873
2622
  if val and not os.environ.get(key):
874
2623
  os.environ[key] = val
@@ -883,57 +2632,578 @@ def _setup_allows_browser_cookies(args: argparse.Namespace, extra_argv: list[str
883
2632
  )
884
2633
 
885
2634
 
886
- SETUP_PASSTHROUGH_FLAGS = {
887
- "--allow-browser-cookies",
888
- "--device-auth",
889
- "--github",
890
- "--openclaw",
891
- }
2635
+ SETUP_PASSTHROUGH_FLAGS = {
2636
+ "--allow-browser-cookies",
2637
+ "--device-auth",
2638
+ "--github",
2639
+ "--github-start",
2640
+ "--github-poll",
2641
+ "--openclaw",
2642
+ "--store-key",
2643
+ }
2644
+
2645
+ STORE_KEY_FLAG = "--store-key"
2646
+
2647
+
2648
+ def _split_store_key(extra_argv: list[str]) -> tuple[bool, str, list[str]]:
2649
+ """Pull ``--store-key <NAME>`` / ``--store-key=<NAME>`` out of ``extra_argv``.
2650
+
2651
+ Returns ``(present, name, remaining)``. ``name`` is "" when the flag has
2652
+ no value; ``remaining`` is every other passthrough token, so the regular
2653
+ allowlist check still applies to them.
2654
+ """
2655
+ present = False
2656
+ name = ""
2657
+ remaining: list[str] = []
2658
+ i = 0
2659
+ while i < len(extra_argv):
2660
+ arg = extra_argv[i]
2661
+ if arg == STORE_KEY_FLAG:
2662
+ present = True
2663
+ if i + 1 < len(extra_argv) and not extra_argv[i + 1].startswith("-"):
2664
+ name = extra_argv[i + 1]
2665
+ i += 2
2666
+ continue
2667
+ i += 1
2668
+ continue
2669
+ if arg.startswith(STORE_KEY_FLAG + "="):
2670
+ present = True
2671
+ name = arg[len(STORE_KEY_FLAG) + 1:]
2672
+ i += 1
2673
+ continue
2674
+ remaining.append(arg)
2675
+ i += 1
2676
+ return present, name, remaining
2677
+
2678
+
2679
+ # One credential line: longer than any real token, short enough that a
2680
+ # misdirected stream on stdin cannot grow memory.
2681
+ STORE_KEY_MAX_BYTES = 64 * 1024
2682
+
2683
+
2684
+ def _run_store_key(name: str) -> int:
2685
+ """``setup --store-key <NAME>``: persist one allowlisted credential from stdin.
2686
+
2687
+ Reads exactly one line from stdin (bounded to ``STORE_KEY_MAX_BYTES``),
2688
+ strips whitespace, and writes it to the global ``.env`` as a 0o600 secret
2689
+ through ``setup_wizard.write_api_key``. An existing line for the same
2690
+ name is replaced, so a rejected credential can be rotated by running the
2691
+ command again. The value never reaches stdout or stderr: stdout carries
2692
+ ``NAME=****`` plus a JSON line ``{"persisted": bool, "key": NAME}``. A
2693
+ name outside ``env.KEYCHAIN_KEYS`` or an empty value exits 2 without
2694
+ echoing anything.
2695
+ """
2696
+ from lib import setup_wizard
2697
+
2698
+ if name not in env.KEYCHAIN_KEYS:
2699
+ # Do not enumerate the allowlist here: on an official-only host a
2700
+ # failure hint must not name the legacy credential keys.
2701
+ sys.stderr.write(
2702
+ "[last30days] setup --store-key: unknown or missing key name "
2703
+ "(must be a credential name the engine loads from its .env; "
2704
+ "see CONFIGURATION.md).\n"
2705
+ )
2706
+ return 2
2707
+ value = sys.stdin.readline(STORE_KEY_MAX_BYTES).strip()
2708
+ if not value:
2709
+ sys.stderr.write(
2710
+ f"[last30days] setup --store-key {name}: empty value on stdin; "
2711
+ "pipe the credential as a single line.\n"
2712
+ )
2713
+ return 2
2714
+ persisted = bool(
2715
+ setup_wizard.write_api_key(env.CONFIG_FILE, value, key_name=name, replace=True)
2716
+ )
2717
+ print(f"{name}=****")
2718
+ print(json.dumps({"persisted": persisted, "key": name}))
2719
+ return 0 if persisted else 1
2720
+
2721
+ SKILL_ONLY_FLAGS = {
2722
+ "--agent",
2723
+ }
2724
+
2725
+ # Doctor passthrough: `doctor --json` / `doctor --cached` mirror the setup
2726
+ # passthrough pattern (neither is a global parser flag; they only mean
2727
+ # something to doctor). `--cached` serves the stored doctor-cache.json report
2728
+ # within its TTL and falls through to a live run otherwise.
2729
+ DOCTOR_PASSTHROUGH_FLAGS = {
2730
+ "--json",
2731
+ "--cached",
2732
+ "--postmortem",
2733
+ "--probe",
2734
+ }
2735
+
2736
+
2737
+ def _looks_inline_json(value: str) -> bool:
2738
+ """True when a --x-posts argument is JSON text rather than a path."""
2739
+ stripped = value.strip()
2740
+ return stripped.startswith(("{", "[")) or "\n" in value
2741
+
2742
+
2743
+ def _comparison_requested(args: argparse.Namespace, topic: str) -> bool:
2744
+ """Whether this invocation is a comparison run (vs-topic or competitor flags)."""
2745
+ from lib import planner as _planner
2746
+
2747
+ return any(
2748
+ value is not None
2749
+ for value in (args.competitors, args.competitors_list, args.competitors_plan)
2750
+ ) or len(_planner._comparison_entities(topic, uncapped=True)) >= 2
2751
+
2752
+
2753
+ LAW7_HOST_PLAN_EXIT = 2
2754
+
2755
+
2756
+ def _law7_host_plan_message(signal: str) -> str:
2757
+ return (
2758
+ "[Planner] LAW 7: no --plan passed, and this run is agent-hosted "
2759
+ f"({signal} is set). The engine stopped before retrieval and did not "
2760
+ "call its internal planner. If you are the reasoning model hosting "
2761
+ "this skill, YOU ARE the planner; no API key or provider is needed. "
2762
+ "Write the JSON query plan (references/research-runbook.md Step 0.75) "
2763
+ "to a tmpfile with a "
2764
+ "quoted heredoc - QUERY_PLAN_FILE=$(mktemp "
2765
+ "\"${TMPDIR:-/tmp}/last30days-plan.XXXXXX\"); "
2766
+ "cat >| \"$QUERY_PLAN_FILE\" <<'PLAN_EOF' ... PLAN_EOF - and re-run "
2767
+ "with --plan \"$QUERY_PLAN_FILE\" (see Research Execution in that "
2768
+ "runbook). With no "
2769
+ "web-search tool, pass --auto-resolve instead. For headless or cron "
2770
+ f"runs under an agent, set {env.ALLOW_ENGINE_PLAN_VAR}=1 to let the "
2771
+ "engine plan internally.\n"
2772
+ )
2773
+
2774
+
2775
+ def _law7_host_plan_signal(args: argparse.Namespace, topic: str) -> str:
2776
+ """Identify a blocked agent run before configuration probes run."""
2777
+ if not topic:
2778
+ return ""
2779
+ if args.plan or args.mock or args.hiring_signals or args.auto_resolve:
2780
+ return ""
2781
+ if _comparison_requested(args, topic):
2782
+ return ""
2783
+ if env.engine_plan_allowed():
2784
+ return ""
2785
+ return env.agent_host_signal()
2786
+
2787
+
2788
+ def _law7_host_plan_gate(args: argparse.Namespace, topic: str) -> int | None:
2789
+ """Stop a plan-less agent research run after dispatch and cache exemptions."""
2790
+ signal = _law7_host_plan_signal(args, topic)
2791
+ if not signal:
2792
+ return None
2793
+ sys.stderr.write(_law7_host_plan_message(signal))
2794
+ return LAW7_HOST_PLAN_EXIT
2795
+
2796
+
2797
+ def _read_x_envelope(
2798
+ path: str,
2799
+ topic: str,
2800
+ args: argparse.Namespace,
2801
+ *,
2802
+ x_handle: str | None,
2803
+ x_related: list[str] | None,
2804
+ ) -> x_envelope.Envelope:
2805
+ """Validate a host-fetched X envelope against this run's window and topic."""
2806
+ from_date, to_date = dates.get_date_range(
2807
+ args.lookback_days or 30, as_of_date=args.as_of_date
2808
+ )
2809
+ return x_envelope.read(
2810
+ path,
2811
+ (from_date, to_date),
2812
+ topic,
2813
+ handles=[x_handle] if x_handle else [],
2814
+ related=[h for h in (x_related or []) if h and h.strip()],
2815
+ )
2816
+
2817
+
2818
+ def _attach_entity_envelopes(comp_plan: dict[str, dict], args: argparse.Namespace) -> None:
2819
+ """Validate every per-entity ``x_posts`` path in a --competitors-plan.
2820
+
2821
+ Each envelope is checked against its own entity (topic) and that entry's
2822
+ ``x_handle``/``x_related`` handles, and stored on the entry as
2823
+ ``_x_envelope`` for the entity sub-run. Raises EnvelopeContractError.
2824
+ """
2825
+ for entry in comp_plan.values():
2826
+ raw = entry.get("x_posts")
2827
+ if not raw:
2828
+ continue
2829
+ if not isinstance(raw, str) or _looks_inline_json(raw):
2830
+ raise x_envelope.EnvelopeContractError(
2831
+ f"--competitors-plan entry {entry.get('_name', '')!r}: x_posts must "
2832
+ "be a file path to a last30days-x-posts/1 envelope, never inline JSON. "
2833
+ "Rewrite the plan entry, or drop its x_posts field."
2834
+ )
2835
+ related = entry.get("x_related") if isinstance(entry.get("x_related"), list) else None
2836
+ entry["_x_envelope"] = _read_x_envelope(
2837
+ raw, str(entry.get("_name") or ""), args,
2838
+ x_handle=entry.get("x_handle") if isinstance(entry.get("x_handle"), str) else None,
2839
+ x_related=[str(h) for h in related] if related else None,
2840
+ )
2841
+
2842
+
2843
+ def _combine_envelope_digests(main_sha256: str | None, entity_sha256: dict[str, str]) -> str | None:
2844
+ """One digest binding the last-report cache to every envelope a run uses.
2845
+
2846
+ A single top-level envelope is bound by its own file digest; per-entity
2847
+ comparison envelopes are folded, name-sorted, into one digest. Both the
2848
+ cache write (validated envelopes) and the cache lookup (planned paths)
2849
+ must go through here so a comparison cache can be reused.
2850
+ """
2851
+ parts: list[str] = []
2852
+ if main_sha256:
2853
+ parts.append(main_sha256)
2854
+ for name in sorted(entity_sha256):
2855
+ parts.append(f"{name}:{entity_sha256[name]}")
2856
+ if not parts:
2857
+ return None
2858
+ if len(parts) == 1 and main_sha256:
2859
+ return main_sha256
2860
+ return hashlib.sha256("|".join(parts).encode("utf-8")).hexdigest()
2861
+
2862
+
2863
+ def _x_envelope_digest(
2864
+ main: x_envelope.Envelope | None, comp_plan: dict[str, dict] | None
2865
+ ) -> str | None:
2866
+ """Digest of the validated envelopes this run used (cache write side)."""
2867
+ entity_sha256 = {
2868
+ name: entry["_x_envelope"].sha256
2869
+ for name, entry in (comp_plan or {}).items()
2870
+ if entry.get("_x_envelope") is not None
2871
+ }
2872
+ return _combine_envelope_digests(main.sha256 if main is not None else None, entity_sha256)
2873
+
2874
+
2875
+ def _validate_extra_argv(parser: argparse.ArgumentParser, topic: str, extra_argv: list[str]) -> None:
2876
+ if not extra_argv:
2877
+ return
2878
+ if topic.lower() == "setup":
2879
+ # --store-key carries a value token; the name itself is allowlisted
2880
+ # later in _run_store_key, not here.
2881
+ _, _, extra_argv = _split_store_key(extra_argv)
2882
+ unsupported = [arg for arg in extra_argv if arg not in SETUP_PASSTHROUGH_FLAGS]
2883
+ if unsupported:
2884
+ parser.error(
2885
+ "unsupported setup argument(s): "
2886
+ + ", ".join(unsupported)
2887
+ + f"; supported setup passthrough flags are {', '.join(sorted(SETUP_PASSTHROUGH_FLAGS))}"
2888
+ )
2889
+ return
2890
+ if topic.lower() == "doctor":
2891
+ unsupported = [arg for arg in extra_argv if arg not in DOCTOR_PASSTHROUGH_FLAGS]
2892
+ if unsupported:
2893
+ parser.error(
2894
+ "unsupported doctor argument(s): "
2895
+ + ", ".join(unsupported)
2896
+ + f"; supported doctor passthrough flags are {', '.join(sorted(DOCTOR_PASSTHROUGH_FLAGS))}"
2897
+ )
2898
+ return
2899
+ skill_only = [arg for arg in extra_argv if arg in SKILL_ONLY_FLAGS]
2900
+ other_unknown = [arg for arg in extra_argv if arg not in SKILL_ONLY_FLAGS]
2901
+ if skill_only:
2902
+ message = (
2903
+ "unsupported Python CLI argument(s): "
2904
+ + ", ".join(skill_only)
2905
+ + "; these are skill arguments and must not be forwarded to scripts/last30days.py"
2906
+ )
2907
+ if other_unknown:
2908
+ message += "; also unsupported: " + ", ".join(other_unknown)
2909
+ parser.error(message)
2910
+ parser.error("unsupported Python CLI argument(s): " + ", ".join(extra_argv))
2911
+
2912
+
2913
+ def _config_policy_for_args(args: argparse.Namespace, topic: str, extra_argv: list[str]) -> env.ConfigLoadPolicy:
2914
+ normalized_topic = topic.lower()
2915
+ is_library_command = (
2916
+ normalized_topic == "library feed"
2917
+ or normalized_topic == "library search"
2918
+ or normalized_topic.startswith("library search ")
2919
+ )
2920
+ # Queue commands are local SQLite reads/writes: like library commands they
2921
+ # must never trigger browser-cookie extraction or Keychain prompts.
2922
+ is_queue_command = (
2923
+ normalized_topic == "queue list"
2924
+ or normalized_topic == "queue cover"
2925
+ or normalized_topic.startswith("queue cover ")
2926
+ )
2927
+ is_cached_verification = bool(getattr(args, "verify_freshness", None)) and not normalized_topic
2928
+ if args.no_browser_cookies:
2929
+ browser_mode = "off"
2930
+ elif (
2931
+ args.diagnose or args.preflight or normalized_topic == "doctor"
2932
+ or is_library_command or is_queue_command or is_cached_verification
2933
+ ):
2934
+ # doctor is plan-only like --diagnose: it must never read cookies.
2935
+ # Cache-only freshness verification hits only point APIs (Polymarket,
2936
+ # GitHub, StockTwits) - no cookie-backed source, so no Keychain prompt.
2937
+ browser_mode = "plan_only"
2938
+ elif normalized_topic == "setup":
2939
+ browser_mode = "read" if _setup_allows_browser_cookies(args, extra_argv) else "off"
2940
+ elif _law7_host_plan_signal(args, topic):
2941
+ # Hosted and cached paths need config before dispatch, but no browser read.
2942
+ browser_mode = "plan_only"
2943
+ else:
2944
+ browser_mode = "read"
2945
+ return env.ConfigLoadPolicy(
2946
+ browser_cookies=browser_mode,
2947
+ inspect_ignored_project_config=args.diagnose or args.preflight or normalized_topic == "doctor",
2948
+ )
2949
+
2950
+
2951
+ def _run_library_feed(args: argparse.Namespace, config: dict[str, object]) -> int:
2952
+ """Generate the local research index/feed and optionally publish it."""
2953
+ from lib import feed, html_publish, library
2954
+
2955
+ if args.publish_html:
2956
+ sys.stderr.write(
2957
+ "[last30days] library feed uses --publish, not --publish-html.\n"
2958
+ )
2959
+ return 2
2960
+ if args.output:
2961
+ sys.stderr.write(
2962
+ "[last30days] library feed writes index.html and feed.xml to --save-dir; "
2963
+ "--output is not supported.\n"
2964
+ )
2965
+ return 2
2966
+
2967
+ memory_dir = Path(args.save_dir).expanduser() if args.save_dir else library.DEFAULT_MEMORY_DIR
2968
+ output_dir = memory_dir.resolve()
2969
+ # Scoped libraries (--save-dir) must not mix in the global briefing
2970
+ # archive: a client-specific or publishable feed pulling unrelated default
2971
+ # briefings could publish them publicly. The default library keeps the
2972
+ # archive; a scoped one reads only its own directory.
2973
+ briefs_dir = (
2974
+ library.DEFAULT_BRIEFS_DIR if not args.save_dir else memory_dir / "briefings"
2975
+ )
2976
+ entries, notes = library.scan_library(memory_dir, briefs_dir)
2977
+ feed_author = str(
2978
+ config.get("LAST30DAYS_LIBRARY_OWNER") or "last30days research library"
2979
+ )
2980
+ output_dir.mkdir(parents=True, exist_ok=True)
2981
+ library_id = library.get_or_create_library_id(output_dir)
2982
+ rendered_briefs_dir = output_dir / "briefs"
2983
+ has_private_entries = any(
2984
+ render.PRIVATE_CORPUS_START in entry.content for entry in entries
2985
+ )
2986
+ _ensure_output_directory(rendered_briefs_dir, private=has_private_entries)
2987
+
2988
+ def _preserve_hand_written_page(existing_path: Path, generated_marker: str) -> None:
2989
+ """Back up any page library feed did not generate before overwriting it."""
2990
+ if not existing_path.exists():
2991
+ return
2992
+ try:
2993
+ marker_found = generated_marker in existing_path.read_text(encoding="utf-8")
2994
+ except (OSError, UnicodeDecodeError):
2995
+ marker_found = False
2996
+ if marker_found:
2997
+ return
2998
+ backup = existing_path.with_suffix(existing_path.suffix + ".bak")
2999
+ counter = 1
3000
+ while backup.exists():
3001
+ backup = existing_path.with_suffix(f"{existing_path.suffix}.bak{counter}")
3002
+ counter += 1
3003
+ existing_path.replace(backup)
3004
+ sys.stderr.write(
3005
+ f"[last30days] {existing_path.name} was not generated by "
3006
+ f"library feed; preserved the original at {backup.name}\n"
3007
+ )
3008
+
3009
+ publishable_brief_documents: dict[str, str] = {}
3010
+ for entry in entries:
3011
+ rendered = html_render.render_library_brief(entry)
3012
+ target = rendered_briefs_dir / entry.output_name
3013
+ _preserve_hand_written_page(target, html_render.LIBRARY_BRIEF_MARKER)
3014
+ save_rendered_output(
3015
+ rendered,
3016
+ str(target),
3017
+ private=render.PRIVATE_CORPUS_START in entry.content,
3018
+ )
3019
+ publishable_brief_documents[entry.entry_id] = html_render.render_library_brief(
3020
+ entry, include_private=False
3021
+ )
3022
+
3023
+ current_brief_names = {entry.output_name for entry in entries}
3024
+ for path in rendered_briefs_dir.glob("*.html"):
3025
+ is_orphan = path.name not in current_brief_names
3026
+ if not (is_orphan and library.is_generated_brief_name(path.name)):
3027
+ continue
3028
+ # A generated-looking name is not proof of ownership; only prune
3029
+ # pages that carry the renderer's own marker.
3030
+ try:
3031
+ generated = html_render.LIBRARY_BRIEF_MARKER in path.read_text(
3032
+ encoding="utf-8"
3033
+ )
3034
+ except (OSError, UnicodeDecodeError):
3035
+ generated = False
3036
+ if generated:
3037
+ path.unlink()
3038
+
3039
+ feed_xml = feed.render_atom(entries, library_id=library_id, author=feed_author)
3040
+ index_html = html_render.render_library_index(entries)
3041
+ feed_path = output_dir / "feed.xml"
3042
+ index_path = output_dir / "index.html"
3043
+ _preserve_hand_written_page(feed_path, "urn:last30days:research-library")
3044
+ _preserve_hand_written_page(
3045
+ index_path, "Generated locally by <strong>last30days</strong>"
3046
+ )
3047
+ feed_path.write_text(feed_xml, encoding="utf-8")
3048
+ index_path.write_text(index_html, encoding="utf-8")
3049
+
3050
+ for note in notes:
3051
+ sys.stderr.write(f"[last30days] Library note: {note}\n")
3052
+ sys.stderr.write(
3053
+ f"[last30days] Library feed generated {len(entries)} brief(s): "
3054
+ f"{index_path} and {feed_path}\n"
3055
+ )
3056
+
3057
+ if args.publish:
3058
+ password = _publish_password_for_args(args, config)
3059
+ entry_urls: dict[str, str] = {}
3060
+ try:
3061
+ brief_results = html_publish.publish_html_documents(
3062
+ publishable_brief_documents,
3063
+ password=password,
3064
+ )
3065
+ entry_urls = {
3066
+ entry_id: str(result["url"])
3067
+ for entry_id, result in brief_results.items()
3068
+ }
3069
+ if batch_error := getattr(brief_results, "error", None):
3070
+ raise batch_error
3071
+ published_index = html_render.render_library_index(
3072
+ entries,
3073
+ entry_urls=entry_urls,
3074
+ feed_url=None,
3075
+ )
3076
+ index_result = html_publish.publish_html(published_index, password=password)
3077
+ index_url = str(index_result["url"])
3078
+ except (html_publish.HtmlPublishError, KeyError, OSError) as exc:
3079
+ sys.stderr.write(f"[last30days] Library publish failed: {exc}\n")
3080
+ if entry_urls:
3081
+ sys.stderr.write(
3082
+ f"[last30days] Partial publish: {len(entry_urls)} public brief "
3083
+ "page(s) were created before the failure.\n"
3084
+ )
3085
+ return 1
3086
+
3087
+ # Keep the local artifacts useful as a record of the live publication.
3088
+ feed_path.write_text(
3089
+ feed.render_atom(
3090
+ entries,
3091
+ library_id=library_id,
3092
+ entry_urls=entry_urls,
3093
+ author=feed_author,
3094
+ ),
3095
+ encoding="utf-8",
3096
+ )
3097
+ index_path.write_text(
3098
+ html_render.render_library_index(entries, entry_urls=entry_urls),
3099
+ encoding="utf-8",
3100
+ )
3101
+ sys.stderr.write(f"[last30days] Published library to {index_url}\n")
3102
+ sys.stderr.write(f"[last30days] Local Atom feed: {feed_path}\n")
3103
+ print(
3104
+ f"Library: {index_url}\nFeed: {feed_path}\n"
3105
+ "Atom feed is local; host feed.xml on any static host (for example, GitHub Pages) "
3106
+ "to make it subscribable."
3107
+ )
3108
+ return 0
3109
+
3110
+ print(
3111
+ f"Library: {index_path}\nFeed: {feed_path}\n"
3112
+ "Atom feed is local; host feed.xml on any static host (for example, GitHub Pages) "
3113
+ "to make it subscribable."
3114
+ )
3115
+ return 0
3116
+
892
3117
 
893
- SKILL_ONLY_FLAGS = {
894
- "--agent",
895
- }
3118
+ def _run_library_search(
3119
+ args: argparse.Namespace,
3120
+ config: dict[str, object],
3121
+ query: str,
3122
+ ) -> int:
3123
+ """Search saved briefs and store sightings without network access."""
3124
+ from lib import library, library_index
896
3125
 
3126
+ if not query.strip():
3127
+ sys.stderr.write("[last30days] library search requires a non-empty query.\n")
3128
+ return 2
3129
+ if args.publish or args.publish_html:
3130
+ sys.stderr.write("[last30days] library search does not publish output.\n")
3131
+ return 2
3132
+ if args.emit != "compact":
3133
+ sys.stderr.write("[last30days] library search currently supports text output only.\n")
3134
+ return 2
3135
+ if args.output:
3136
+ sys.stderr.write(
3137
+ "[last30days] library search prints to stdout; --output is not supported.\n"
3138
+ )
3139
+ return 2
897
3140
 
898
- def _validate_extra_argv(parser: argparse.ArgumentParser, topic: str, extra_argv: list[str]) -> None:
899
- if not extra_argv:
900
- return
901
- if topic.lower() == "setup":
902
- unsupported = [arg for arg in extra_argv if arg not in SETUP_PASSTHROUGH_FLAGS]
903
- if unsupported:
904
- parser.error(
905
- "unsupported setup argument(s): "
906
- + ", ".join(unsupported)
907
- + f"; supported setup passthrough flags are {', '.join(sorted(SETUP_PASSTHROUGH_FLAGS))}"
908
- )
909
- return
910
- skill_only = [arg for arg in extra_argv if arg in SKILL_ONLY_FLAGS]
911
- other_unknown = [arg for arg in extra_argv if arg not in SKILL_ONLY_FLAGS]
912
- if skill_only:
913
- message = (
914
- "unsupported Python CLI argument(s): "
915
- + ", ".join(skill_only)
916
- + "; these are skill arguments and must not be forwarded to scripts/last30days.py"
3141
+ memory_dir = Path(args.save_dir).expanduser() if args.save_dir else library.DEFAULT_MEMORY_DIR
3142
+ try:
3143
+ matches, synced = library_index.sync_and_search(
3144
+ query,
3145
+ memory_dir=memory_dir,
3146
+ briefs_dir=(
3147
+ memory_dir / "briefings" if args.save_dir else library.DEFAULT_BRIEFS_DIR
3148
+ ),
3149
+ db_path=(
3150
+ memory_dir.resolve() / ".last30days-library.db"
3151
+ if args.save_dir else library_index.DEFAULT_LIBRARY_DB
3152
+ ),
3153
+ # A scoped search must never merge in the shared store: one
3154
+ # client's sightings would leak into another client's scope. A
3155
+ # scoped store is read only if it exists inside the save dir.
3156
+ store_db_path=(
3157
+ memory_dir.resolve() / "research.db"
3158
+ if args.save_dir else library_index.DEFAULT_STORE_DB
3159
+ ),
917
3160
  )
918
- if other_unknown:
919
- message += "; also unsupported: " + ", ".join(other_unknown)
920
- parser.error(message)
921
- parser.error("unsupported Python CLI argument(s): " + ", ".join(extra_argv))
3161
+ except library_index.LibrarySearchUnavailable as exc:
3162
+ sys.stderr.write(f"[last30days] Library search unavailable: {exc}.\n")
3163
+ return 2
3164
+ except (OSError, sqlite3.DatabaseError) as exc:
3165
+ sys.stderr.write(f"[last30days] Library search failed: {exc}.\n")
3166
+ return 1
3167
+ for note in synced.notes:
3168
+ sys.stderr.write(f"[last30days] Library note: {note}\n")
3169
+ if synced.rebuilt:
3170
+ sys.stderr.write("[last30days] Rebuilt a corrupt library search index.\n")
3171
+ print(render.render_library_search(query, matches), end="")
3172
+ return 0
922
3173
 
923
3174
 
924
- def _config_policy_for_args(args: argparse.Namespace, topic: str, extra_argv: list[str]) -> env.ConfigLoadPolicy:
925
- if args.no_browser_cookies:
926
- browser_mode = "off"
927
- elif args.diagnose or args.preflight:
928
- browser_mode = "plan_only"
929
- elif topic.lower() == "setup":
930
- browser_mode = "read" if _setup_allows_browser_cookies(args, extra_argv) else "off"
931
- else:
932
- browser_mode = "read"
933
- return env.ConfigLoadPolicy(
934
- browser_cookies=browser_mode,
935
- inspect_ignored_project_config=args.diagnose or args.preflight,
936
- )
3175
+ def _looks_like_entity_topic(topic: str) -> bool:
3176
+ """Whether a topic names a person, company, or product rather than a theme.
3177
+
3178
+ Keys on brevity, not capitalization. People type lowercase: "bentgo",
3179
+ "peter steinberger" and "getenergy.com" are entity searches every bit as
3180
+ much as their title-cased forms, and requiring a capital meant the most
3181
+ common real-world spelling never resolved a handle.
3182
+
3183
+ A short topic is an entity search; a longer one is a theme. "Peter
3184
+ Steinberger", "bentgo" and "getenergy.com" qualify; "best AI coding tools
3185
+ 2026" and "how to build agents that scale" do not. Question-shaped topics
3186
+ are themes regardless of length.
3187
+
3188
+ Used only to decide whether resolving an X handle is worth one web search,
3189
+ so a false negative costs the old behavior and a false positive costs a
3190
+ single search.
3191
+ """
3192
+ text = (topic or "").strip()
3193
+ if not text or text.endswith("?"):
3194
+ return False
3195
+ words = [w for w in re.findall(r"[A-Za-z0-9_.@'-]+", text) if w]
3196
+ if not words or len(words) > 4:
3197
+ return False
3198
+ if any(w.startswith("@") for w in words):
3199
+ return True
3200
+ # A theme reads as a phrase built from common words; an entity does not.
3201
+ common = {
3202
+ "best", "top", "how", "why", "what", "when", "vs", "versus", "guide",
3203
+ "tips", "review", "reviews", "news", "latest", "update", "updates",
3204
+ "trends", "tools", "and", "or", "for", "the", "with", "about",
3205
+ }
3206
+ return not any(w.lower() in common for w in words)
937
3207
 
938
3208
 
939
3209
  def main() -> int:
@@ -941,13 +3211,106 @@ def main() -> int:
941
3211
  # Use parse_known_args so setup sub-flags (--device-auth, --github,
942
3212
  # --openclaw) pass through without argparse hard-exiting.
943
3213
  args, extra_argv = parser.parse_known_args()
3214
+ if args.record_fixtures:
3215
+ with http.recording_requests(Path(args.record_fixtures)):
3216
+ return _main(parser, args, extra_argv)
3217
+ return _main(parser, args, extra_argv)
3218
+
3219
+
3220
+ def _quality_research_results(report, diag, yt_fetch_stats):
3221
+ youtube_items = report.items_by_source.get("youtube") or []
3222
+ instagram_items = report.items_by_source.get("instagram") or []
3223
+ x_outcome = report.source_status.get("x")
3224
+ x_degraded_error = None
3225
+ if (
3226
+ report.items_by_source.get("x")
3227
+ and x_outcome is not None
3228
+ and x_outcome.detail
3229
+ and x_outcome.detail.startswith("X served via ")
3230
+ and " after xai:" in x_outcome.detail
3231
+ ):
3232
+ x_degraded_error = x_outcome.detail
3233
+ return {
3234
+ "active_sources": diag.get("available_sources") or [],
3235
+ "youtube_videos_count": len(youtube_items),
3236
+ "youtube_transcripts_count": sum(
3237
+ 1 for it in youtube_items
3238
+ if (it.metadata.get("transcript_highlights") or it.metadata.get("transcript_snippet"))
3239
+ ),
3240
+ "youtube_error": report.errors_by_source.get("youtube"),
3241
+ "x_error": report.errors_by_source.get("x"),
3242
+ "x_degraded_error": x_degraded_error,
3243
+ "youtube_captions_disabled_count": sum(
3244
+ 1 for it in youtube_items if it.metadata.get("captions_disabled")
3245
+ ),
3246
+ "youtube_transcript_fetch_attempts": yt_fetch_stats["attempts"],
3247
+ "youtube_transcript_fetch_failures": yt_fetch_stats["failures"],
3248
+ "instagram_items_count": len(instagram_items),
3249
+ }
3250
+
3251
+
3252
+ def _main(
3253
+ parser: argparse.ArgumentParser,
3254
+ args: argparse.Namespace,
3255
+ extra_argv: list[str],
3256
+ ) -> int:
944
3257
  if args.debug:
945
3258
  os.environ["LAST30DAYS_DEBUG"] = "1"
946
3259
 
3260
+ if args.welcome:
3261
+ from lib import setup_wizard
3262
+ print(setup_wizard.render_welcome())
3263
+ return 0
3264
+
947
3265
  topic = " ".join(args.topic).strip()
948
3266
  original_topic = topic
949
3267
  _validate_extra_argv(parser, topic, extra_argv)
3268
+ if args.resolve_save_dir:
3269
+ print(env.resolve_memory_dir(args.save_dir))
3270
+ return 0
3271
+ if args.x_posts is not None and _looks_inline_json(args.x_posts):
3272
+ sys.stderr.write(
3273
+ "[last30days] --x-posts accepts a file path only (inline JSON is not "
3274
+ "accepted); write the envelope to a .json file and pass its path.\n"
3275
+ )
3276
+ return 2
3277
+ if args.publish and topic.lower() != "library feed":
3278
+ sys.stderr.write(
3279
+ "[last30days] --publish is only supported by the 'library feed' command.\n"
3280
+ )
3281
+ return 2
3282
+ if topic.lower() == "setup":
3283
+ # Persisting a credential needs no config load (no Keychain / pass
3284
+ # probes, no cookie policy), so it dispatches before get_config.
3285
+ store_key_present, store_key_name, _ = _split_store_key(extra_argv)
3286
+ if store_key_present:
3287
+ return _run_store_key(store_key_name)
3288
+
950
3289
  config = env.get_config(policy=_config_policy_for_args(args, topic, extra_argv))
3290
+ if args.perplexity_search_type is not None:
3291
+ config["LAST30DAYS_PERPLEXITY_SEARCH_TYPE"] = args.perplexity_search_type
3292
+ # One memo per command: comparison mode runs pipeline.run per entity in
3293
+ # parallel, so the reset must not live inside the pipeline.
3294
+ http.reset_reddit_keyless_memo()
3295
+ reddit.reset_scrapecreators_memo()
3296
+ fetch_memo.reset()
3297
+ resolved_corpus_dirs = corpus.resolve_directories(
3298
+ args.corpus, config.get("LAST30DAYS_CORPUS_DIRS")
3299
+ )
3300
+ # EXCLUDE_SOURCES=corpus disables corpus retrieval entirely; the hosted
3301
+ # privacy bypass below must use the same predicate, or hosted users with
3302
+ # configured-but-excluded dirs silently lose the remote backend.
3303
+ excluded_sources = {
3304
+ value.strip().lower()
3305
+ for value in str(config.get("EXCLUDE_SOURCES") or "").split(",")
3306
+ if value.strip()
3307
+ }
3308
+ if "corpus" in excluded_sources:
3309
+ resolved_corpus_dirs = []
3310
+ if resolved_corpus_dirs:
3311
+ config["_CORPUS_DIRS"] = [str(path) for path in resolved_corpus_dirs]
3312
+ if _config_truthy(config.get("LAST30DAYS_CORPUS_IN_EXPORT")):
3313
+ config["_CORPUS_IN_EXPORT"] = True
951
3314
  _propagate_config_to_environ(config)
952
3315
 
953
3316
  # Env-var fallback for --save-dir, mirroring the LAST30DAYS_STORE pattern below.
@@ -984,6 +3347,30 @@ def main() -> int:
984
3347
  print(permission_preflight.render_text(preflight), end="")
985
3348
  return 0
986
3349
 
3350
+ # Handle doctor subcommand: topic-word dispatch mirroring setup (exact
3351
+ # match only, so multi-word research topics containing "doctor" still
3352
+ # research normally). Aggregates probes/descriptors/prescriptions into
3353
+ # one grouped health surface; always exits 0.
3354
+ if topic.lower() == "doctor":
3355
+ from lib import doctor
3356
+ return doctor.run(
3357
+ config,
3358
+ emit_json=(args.emit == "json" or "--json" in extra_argv),
3359
+ cached="--cached" in extra_argv,
3360
+ postmortem="--postmortem" in extra_argv,
3361
+ probe="--probe" in extra_argv,
3362
+ )
3363
+
3364
+ if topic.lower() == "library feed":
3365
+ return _run_library_feed(args, config)
3366
+ if topic.lower() == "library search" or topic.lower().startswith("library search "):
3367
+ return _run_library_search(args, config, topic[len("library search") :].strip())
3368
+
3369
+ if topic.lower() == "queue list":
3370
+ return _run_queue_list(args, config)
3371
+ if topic.lower() == "queue cover" or topic.lower().startswith("queue cover "):
3372
+ return _run_queue_cover(args, config, topic[len("queue cover") :].strip())
3373
+
987
3374
  # Handle setup subcommand
988
3375
  if topic.lower() == "setup":
989
3376
  from lib import setup_wizard
@@ -991,8 +3378,12 @@ def main() -> int:
991
3378
  results = setup_wizard.run_openclaw_setup(config)
992
3379
  print(json.dumps(results))
993
3380
  return 0
994
- if "--github" in extra_argv or "--device-auth" in extra_argv:
995
- if "--github" in extra_argv:
3381
+ if any(f in extra_argv for f in ("--github", "--device-auth", "--github-start", "--github-poll")):
3382
+ if "--github-start" in extra_argv:
3383
+ results = setup_wizard.run_github_start()
3384
+ elif "--github-poll" in extra_argv:
3385
+ results = setup_wizard.run_github_poll()
3386
+ elif "--github" in extra_argv:
996
3387
  results = setup_wizard.run_github_auth()
997
3388
  else:
998
3389
  results = setup_wizard.run_full_device_auth()
@@ -1000,8 +3391,16 @@ def main() -> int:
1000
3391
  # run, and mask it in stdout so the secret never lands in the host
1001
3392
  # model's captured Bash output.
1002
3393
  api_key = results.get("api_key")
1003
- if results.get("status") == "success" and api_key:
1004
- results["persisted"] = setup_wizard.write_api_key(env.CONFIG_FILE, api_key)
3394
+ status = results.get("status")
3395
+ if api_key:
3396
+ if status == "success":
3397
+ results["persisted"] = setup_wizard.write_api_key(env.CONFIG_FILE, api_key)
3398
+ elif status == "already_registered":
3399
+ results["persisted"] = True # key was already saved
3400
+ else:
3401
+ results.setdefault("persisted", False)
3402
+ # Mask for EVERY status that carries a key, not just success, so
3403
+ # the raw secret never reaches the host model's captured stdout.
1005
3404
  results["api_key"] = setup_wizard.mask_api_key(api_key)
1006
3405
  else:
1007
3406
  results["persisted"] = False
@@ -1012,27 +3411,382 @@ def main() -> int:
1012
3411
  config,
1013
3412
  allow_browser_cookies=_setup_allows_browser_cookies(args, extra_argv),
1014
3413
  )
1015
- # Persist FROM_BROWSER only when every service's cookies came from the
1016
- # SAME single browser — then we can fast-path future runs to it. If
1017
- # different services matched different browsers, or none matched, leave
1018
- # FROM_BROWSER unset so the safe default remains no browser-cookie
1019
- # reads. We deliberately do NOT pin "auto" here (it would re-probe
1020
- # Chrome and re-trigger the prompt) nor a single browser (it would
1021
- # silently skip the service that used the other one).
1022
- found_browsers = set(results.get("cookies_found", {}).values())
1023
- from_browser = found_browsers.pop() if len(found_browsers) == 1 else None
1024
- setup_wizard.write_setup_config(env.CONFIG_FILE, from_browser=from_browser)
1025
- results["env_written"] = True
3414
+ # Keep only successful browsers, including distinct service winners;
3415
+ # "auto" would also probe browsers that did not supply any cookies.
3416
+ found_browsers = dict.fromkeys(
3417
+ "firefox" if browser == "firefox-wsl" else browser
3418
+ for browser in results.get("cookies_found", {}).values()
3419
+ )
3420
+ from_browser = ",".join(found_browsers) or None
3421
+ denied_browsers = (
3422
+ results.get("x_cookie_access_denied", [])
3423
+ if results.get("browser_cookie_scan_attempted") else []
3424
+ )
3425
+ prior_denial = config.get("LAST30DAYS_X_COOKIE_ACCESS_DENIED")
3426
+ denial_marker = (
3427
+ ",".join(denied_browsers) or ("none" if prior_denial else None)
3428
+ if results.get("browser_cookie_scan_attempted") else None
3429
+ )
3430
+ results["env_written"] = setup_wizard.write_setup_config(
3431
+ env.CONFIG_FILE,
3432
+ from_browser=from_browser,
3433
+ x_cookie_access_denied=denial_marker,
3434
+ browser_consent=(
3435
+ None if args.diagnose else _setup_allows_browser_cookies(args, extra_argv)
3436
+ ),
3437
+ )
3438
+ if not results["env_written"]:
3439
+ sys.stderr.write("Setup configuration could not be fully saved; some settings may already be saved.\n")
3440
+ return 1
1026
3441
  sys.stderr.write(setup_wizard.get_setup_status_text(results) + "\n")
1027
3442
  return 0
1028
3443
 
1029
- requested_sources = resolve_requested_sources(args.search, config)
1030
- diag = pipeline.diagnose(config, requested_sources, safe=args.diagnose)
3444
+ # Bare --discover (no domain) is global trending, so the dispatch keys on
3445
+ # "flag present" (is not None), never on the domain string's truthiness.
3446
+ if args.deep_research and not topic:
3447
+ sys.stderr.write(
3448
+ "[last30days] --deep-research requires a normal positional topic; "
3449
+ "it cannot be combined with discovery, drill, or cached-only modes.\n"
3450
+ )
3451
+ return 2
3452
+
3453
+ if args.discover is not None:
3454
+ if topic:
3455
+ sys.stderr.write(
3456
+ "[last30days] --discover supplies the domain and cannot be combined "
3457
+ "with a positional topic.\n"
3458
+ )
3459
+ return 2
3460
+ if args.drill:
3461
+ sys.stderr.write("[last30days] --discover and --drill are mutually exclusive.\n")
3462
+ return 2
3463
+ # Shared guards for EVERY discover invocation - the one-shot and all
3464
+ # three protocol legs - hoisted here so no leg can drift: discovery
3465
+ # sweeps live listings (never --as-of) and has no HTML pipeline yet.
3466
+ if args.as_of_date:
3467
+ sys.stderr.write(
3468
+ "[last30days] --as-of cannot be used with --discover because discovery "
3469
+ "sweeps current live listings.\n"
3470
+ )
3471
+ return 2
3472
+ if args.emit == "html" or args.publish_html:
3473
+ sys.stderr.write("[last30days] discovery mode does not support HTML publishing yet.\n")
3474
+ return 2
3475
+ # The three protocol legs are one-leg-per-invocation: each pairing
3476
+ # below asks for two legs at once, so name the combination and stop.
3477
+ # (--judgments/--angles dispatch on presence, never path truthiness.)
3478
+ for first, second, conflict in (
3479
+ ("--nominate-only", "--judgments", args.nominate_only and args.judgments is not None),
3480
+ ("--nominate-only", "--finalize", args.nominate_only and args.finalize),
3481
+ ("--judgments", "--finalize", args.judgments is not None and args.finalize),
3482
+ ):
3483
+ if conflict:
3484
+ sys.stderr.write(
3485
+ f"[last30days] {first} and {second} are mutually exclusive: "
3486
+ "each runs a different leg of the discovery protocol.\n"
3487
+ )
3488
+ return 2
3489
+ if args.angles is not None and not args.finalize:
3490
+ sys.stderr.write(
3491
+ "[last30days] --angles only applies to --discover --finalize "
3492
+ "runs; add --finalize or drop the flag.\n"
3493
+ )
3494
+ return 2
3495
+ protocol_leg = (
3496
+ args.nominate_only or args.judgments is not None or args.finalize
3497
+ )
3498
+ if protocol_leg and args.mock and not args.save_dir:
3499
+ # Truthiness is right here: an empty --save-dir/env value means
3500
+ # "no save dir", and handoff state would land in the real config
3501
+ # dir - a side effect mock runs must never have.
3502
+ sys.stderr.write(
3503
+ "[last30days] mock protocol legs require --save-dir to stay "
3504
+ "side-effect-free: --mock with --nominate-only/--judgments/"
3505
+ "--finalize would otherwise write handoff state into the real "
3506
+ "config dir.\n"
3507
+ )
3508
+ return 2
3509
+ if protocol_leg:
3510
+ return _run_discover_protocol_leg(args, config)
3511
+ return _run_discover(args, config)
3512
+
3513
+ if args.discover_shallow:
3514
+ # Without --discover this flag would silently no-op into a full
3515
+ # research run - reject it instead of ignoring the requested mode.
3516
+ sys.stderr.write(
3517
+ "[last30days] --discover-shallow only applies to --discover runs; "
3518
+ "add --discover [domain] or drop the flag.\n"
3519
+ )
3520
+ return 2
3521
+
3522
+ # Same orphan rule for every protocol-leg flag: without --discover each
3523
+ # would silently no-op into a normal research run.
3524
+ for flag_label, present in (
3525
+ ("--nominate-only", args.nominate_only),
3526
+ ("--judgments", args.judgments is not None),
3527
+ ("--finalize", args.finalize),
3528
+ ):
3529
+ if present:
3530
+ sys.stderr.write(
3531
+ f"[last30days] {flag_label} only applies to --discover runs; "
3532
+ "add --discover [domain] or drop the flag.\n"
3533
+ )
3534
+ return 2
3535
+ if args.angles is not None:
3536
+ sys.stderr.write(
3537
+ "[last30days] --angles only applies to --discover --finalize runs; "
3538
+ "add --discover --finalize or drop the flag.\n"
3539
+ )
3540
+ return 2
3541
+
3542
+ if args.drill:
3543
+ if topic:
3544
+ sys.stderr.write(
3545
+ "[last30days] --drill uses the cached topic and cannot be "
3546
+ "combined with a new topic.\n"
3547
+ )
3548
+ return 2
3549
+ if args.publish_html and args.emit != "html":
3550
+ sys.stderr.write("[last30days] --publish-html requires --emit=html\n")
3551
+ return 2
3552
+ if args.dedicated_subreddits:
3553
+ config["_dedicated_subreddits"] = [
3554
+ value.strip().removeprefix("r/")
3555
+ for value in args.dedicated_subreddits.split(",")
3556
+ if value.strip()
3557
+ ]
3558
+ if args.polymarket_keywords:
3559
+ config["_polymarket_keywords"] = [
3560
+ value.strip().lower()
3561
+ for value in args.polymarket_keywords.split(",")
3562
+ if value.strip()
3563
+ ]
3564
+ return _run_drill(args, config)
3565
+
3566
+ if args.verify_freshness and not topic:
3567
+ return _run_cached_freshness(args, config)
3568
+
3569
+ if args.lookback_days is None:
3570
+ args.lookback_days = 30
3571
+
3572
+ if args.deep_research and not args.diagnose:
3573
+ from lib import planner as _planner
3574
+
3575
+ if not (
3576
+ config.get("PERPLEXITY_API_KEY")
3577
+ or config.get("OPENROUTER_API_KEY")
3578
+ ):
3579
+ print(
3580
+ "Error: --deep-research requires PERPLEXITY_API_KEY or "
3581
+ "OPENROUTER_API_KEY",
3582
+ file=sys.stderr,
3583
+ )
3584
+ return 1
3585
+ comparison_requested = any(
3586
+ value is not None
3587
+ for value in (
3588
+ args.competitors,
3589
+ args.competitors_list,
3590
+ args.competitors_plan,
3591
+ )
3592
+ ) or len(_planner._comparison_entities(topic, uncapped=True)) >= 2
3593
+ if comparison_requested:
3594
+ sys.stderr.write(
3595
+ "Error: --deep-research cannot be combined with competitor or vs-mode. "
3596
+ "It permits one paid Deep Research run per user action; run each topic "
3597
+ "separately.\n"
3598
+ )
3599
+ return 2
3600
+ config["_deep_research"] = True
3601
+ try:
3602
+ enable_deep_research_source(config)
3603
+ except ValueError as exc:
3604
+ print(f"Error: {exc}", file=sys.stderr)
3605
+ return 2
3606
+
3607
+ # Reject a misspelled configured register before remote submission or any
3608
+ # local source retrieval. Excluded modes resolve to default and remain
3609
+ # unaffected by the register setting.
3610
+ try:
3611
+ _audience_register_for_run(args, config, None)
3612
+ except ValueError as exc:
3613
+ sys.stderr.write(f"[last30days] {exc}\n")
3614
+ return 2
3615
+
3616
+ # Remote API path: when BOTH LAST30DAYS_API_KEY and LAST30DAYS_API_BASE are
3617
+ # set (and --mock is not), the search runs through the configured remote API
3618
+ # instead of local sources; no local provider keys are needed (see
3619
+ # lib/hosted.py). With either env var unset, behavior below is byte-identical
3620
+ # to local-only runs - there is no built-in endpoint.
3621
+ if (
3622
+ topic
3623
+ and resolved_corpus_dirs
3624
+ and env.read_secret_env("LAST30DAYS_API_KEY")
3625
+ and os.environ.get("LAST30DAYS_API_BASE")
3626
+ ):
3627
+ sys.stderr.write(
3628
+ "[last30days] Local corpus configured; bypassing the hosted backend so files stay on this machine.\n"
3629
+ )
3630
+ # An explicit --perplexity-search-type is per-invocation intent the hosted
3631
+ # backend cannot honor, so it runs locally, but only when a direct
3632
+ # PERPLEXITY_API_KEY can apply it; otherwise the switch would trade hosted
3633
+ # coverage for nothing. Key on the parsed CLI flag only: a value from
3634
+ # LAST30DAYS_PERPLEXITY_SEARCH_TYPE must never move routing.
3635
+ elif (
3636
+ topic
3637
+ and args.perplexity_search_type is not None
3638
+ and config.get("PERPLEXITY_API_KEY")
3639
+ and not args.diagnose
3640
+ and not args.mock
3641
+ and not args.record_fixtures
3642
+ and not args.deep_research
3643
+ and env.read_secret_env("LAST30DAYS_API_KEY")
3644
+ and os.environ.get("LAST30DAYS_API_BASE")
3645
+ ):
3646
+ sys.stderr.write(
3647
+ "[last30days] --perplexity-search-type set; bypassing the hosted backend "
3648
+ "because it does not apply the Perplexity search type.\n"
3649
+ )
3650
+ if (
3651
+ topic
3652
+ and not args.diagnose
3653
+ and not args.mock
3654
+ and not args.record_fixtures
3655
+ and env.read_secret_env("LAST30DAYS_API_KEY")
3656
+ and os.environ.get("LAST30DAYS_API_BASE")
3657
+ and not resolved_corpus_dirs
3658
+ and not args.deep_research
3659
+ and (args.perplexity_search_type is None or not config.get("PERPLEXITY_API_KEY"))
3660
+ ):
3661
+ if args.perplexity_search_type is not None:
3662
+ sys.stderr.write(
3663
+ "hosted backend does not apply --perplexity-search-type and no direct "
3664
+ "PERPLEXITY_API_KEY is configured to run it locally; skipping\n"
3665
+ )
3666
+ elif config.get("LAST30DAYS_PERPLEXITY_SEARCH_TYPE"):
3667
+ sys.stderr.write(
3668
+ "hosted backend does not apply LAST30DAYS_PERPLEXITY_SEARCH_TYPE; skipping\n"
3669
+ )
3670
+ if _freshness_enabled(args, config):
3671
+ if args.verify_freshness is True:
3672
+ sys.stderr.write(
3673
+ "[last30days] Freshness verification is not supported by the hosted backend; "
3674
+ "run locally or omit --verify-freshness.\n"
3675
+ )
3676
+ return 2
3677
+ sys.stderr.write(
3678
+ "hosted backend does not support freshness verification; skipping\n"
3679
+ )
3680
+ if args.emit == "json" and args.json_profile == "agent":
3681
+ sys.stderr.write(
3682
+ "[last30days] --json-profile=agent requires the local Report; "
3683
+ "the remote API backend only supports --json-profile=raw.\n"
3684
+ )
3685
+ return 2
3686
+ if args.x_posts is not None:
3687
+ # The envelope is a local-engine contract; the remote API has no
3688
+ # lane to receive it.
3689
+ sys.stderr.write(
3690
+ "[last30days] --x-posts is not supported by the hosted backend; "
3691
+ "run locally or omit --x-posts.\n"
3692
+ )
3693
+ return 2
3694
+ from lib import hosted
3695
+ depth = "deep" if args.deep else "quick" if args.quick else "default"
3696
+ try:
3697
+ audience = _audience_register_for_run(args, config, None)
3698
+ except ValueError as exc:
3699
+ sys.stderr.write(f"[last30days] {exc}\n")
3700
+ return 2
3701
+ hosted_kwargs = {
3702
+ "emit": args.emit,
3703
+ "save_dir": args.save_dir,
3704
+ "save_suffix": args.save_suffix or "",
3705
+ }
3706
+ if audience.name != "default":
3707
+ hosted_kwargs["register"] = audience.name
3708
+ return hosted.run_hosted(topic, depth, **hosted_kwargs)
1031
3709
 
3710
+ requested_sources = resolve_requested_sources(args.search, config)
3711
+ if args.deep_research:
3712
+ requested_sources = add_deep_research_source(requested_sources)
3713
+ # Explicit --trustpilot-domain is user intent: activate the opt-in source
3714
+ # before diagnose/run so the flag cannot silently no-op (#873). Auto-resolve
3715
+ # hints are applied later and must not call this path.
3716
+ cli_trustpilot_domain = (
3717
+ args.trustpilot_domain.strip() if args.trustpilot_domain else ""
3718
+ )
3719
+ if cli_trustpilot_domain:
3720
+ requested_sources = activate_trustpilot_for_explicit_domain(
3721
+ config,
3722
+ requested_sources,
3723
+ reason=f"--trustpilot-domain={cli_trustpilot_domain}",
3724
+ )
3725
+ # Explicit --telegram-sources is user intent: activate the opt-in source
3726
+ # before diagnose/run so the flag cannot silently no-op (same pattern as
3727
+ # Trustpilot #873). Sets TELEGRAM_SOURCES in config for pipeline.
3728
+ cli_telegram_sources = (
3729
+ args.telegram_sources.strip() if args.telegram_sources else ""
3730
+ )
3731
+ if cli_telegram_sources:
3732
+ requested_sources = activate_telegram_for_explicit_sources(
3733
+ config,
3734
+ requested_sources,
3735
+ channels=cli_telegram_sources,
3736
+ )
3737
+ # --devto-tags activates the off-by-default dev.to source for this run
3738
+ # (the --trustpilot-domain pattern): carried on config for the dispatch,
3739
+ # and appended to an explicit or default --search list so it is not
3740
+ # filtered back out. EXCLUDE_SOURCES=devto still wins in the pipeline.
3741
+ cli_devto_tags = [
3742
+ t.strip() for t in (args.devto_tags or "").split(",") if t.strip()
3743
+ ]
3744
+ if cli_devto_tags:
3745
+ config["_devto_tags"] = cli_devto_tags
3746
+ if requested_sources is not None and "devto" not in requested_sources:
3747
+ requested_sources = [*requested_sources, "devto"]
3748
+ # Host-fetched X envelope: validated before diagnose so a present
3749
+ # envelope plans X in (available_sources) and a bad one fails closed here.
3750
+ x_posts_envelope: x_envelope.Envelope | None = None
3751
+ if args.x_posts is not None:
3752
+ if not topic:
3753
+ sys.stderr.write("[last30days] --x-posts requires a research topic.\n")
3754
+ return 2
3755
+ if _comparison_requested(args, topic):
3756
+ sys.stderr.write(
3757
+ "[last30days] --x-posts applies to a single-topic run; on a "
3758
+ "comparison run pass each entity's envelope through the "
3759
+ "x_posts field of its --competitors-plan entry.\n"
3760
+ )
3761
+ return 2
3762
+ try:
3763
+ x_posts_envelope = _read_x_envelope(
3764
+ args.x_posts, topic, args,
3765
+ x_handle=args.x_handle,
3766
+ x_related=args.x_related.split(",") if args.x_related else None,
3767
+ )
3768
+ except x_envelope.EnvelopeContractError as exc:
3769
+ sys.stderr.write(f"[last30days] {exc.message}\n")
3770
+ return 2
1032
3771
  if args.diagnose:
3772
+ diag = pipeline.diagnose(
3773
+ config, requested_sources, safe=True,
3774
+ x_envelope=x_posts_envelope is not None,
3775
+ )
1033
3776
  print(json.dumps(diag, indent=2, sort_keys=True))
1034
3777
  return 0
1035
3778
 
3779
+ # Per-entity host-fetched X envelopes are validated here, on the main
3780
+ # thread and BEFORE the report-cache lookup, so a bad or stale one fails
3781
+ # closed (exit 2) instead of silently dropping that entity inside the
3782
+ # fan-out or being served from a cache built while it was still valid.
3783
+ comp_plan = parse_competitors_plan(args.competitors_plan)
3784
+ try:
3785
+ _attach_entity_envelopes(comp_plan, args)
3786
+ except x_envelope.EnvelopeContractError as exc:
3787
+ sys.stderr.write(f"[last30days] {exc.message}\n")
3788
+ return 2
3789
+
1036
3790
  if not topic:
1037
3791
  parser.print_usage(sys.stderr)
1038
3792
  return 2
@@ -1054,10 +3808,15 @@ def main() -> int:
1054
3808
  sys.stderr.write(refuse_msg)
1055
3809
  return 2
1056
3810
 
1057
- if args.emit == "html" and synthesis_md is not None:
3811
+ if (
3812
+ args.emit == "html"
3813
+ and synthesis_md is not None
3814
+ and not args.deep_research
3815
+ ):
1058
3816
  cached = _load_last_report_cache(
1059
3817
  topic,
1060
3818
  ttl_seconds=_report_cache_ttl_seconds(config),
3819
+ x_envelope_sha256=_x_envelope_digest(x_posts_envelope, comp_plan),
1061
3820
  )
1062
3821
  if cached is not None:
1063
3822
  cached_report, cached_entity_reports, cache_path = cached
@@ -1065,6 +3824,17 @@ def main() -> int:
1065
3824
  f"[last30days] Reusing cached report data from {cache_path}\n"
1066
3825
  )
1067
3826
  sys.stderr.flush()
3827
+ if _freshness_enabled(args, config):
3828
+ _verify_report_set(
3829
+ cached_report,
3830
+ cached_entity_reports,
3831
+ allow_network=not args.mock,
3832
+ )
3833
+ _update_cached_freshness(
3834
+ cache_path,
3835
+ cached_report,
3836
+ cached_entity_reports,
3837
+ )
1068
3838
  return _render_save_and_print(
1069
3839
  args, cached_report, cached_entity_reports, synthesis_md, config
1070
3840
  )
@@ -1074,10 +3844,35 @@ def main() -> int:
1074
3844
  )
1075
3845
  sys.stderr.flush()
1076
3846
 
3847
+ # LAW 7: an agent host that skipped --plan stops before live diagnostics,
3848
+ # auto-resolve, the internal planner, or any source retrieval can spend.
3849
+ law7_exit = _law7_host_plan_gate(args, topic)
3850
+ if law7_exit is not None:
3851
+ return law7_exit
3852
+
3853
+ diag = pipeline.diagnose(
3854
+ config, requested_sources, safe=False,
3855
+ x_envelope=x_posts_envelope is not None,
3856
+ )
3857
+
3858
+ # Competitor sub-runs shallow-copy this config. The shared object makes the
3859
+ # paid Perplexity cap command-wide and thread-safe across that fanout. Keep
3860
+ # this runtime-only object out of the safe diagnose configuration contract.
3861
+ config["_perplexity_paid_budget"] = pipeline.PaidSourceBudget()
3862
+
1077
3863
  progress = ui.ProgressDisplay(topic, show_banner=True)
1078
3864
  progress.start_processing()
1079
3865
 
1080
3866
  depth = "deep" if args.deep else "quick" if args.quick else "default"
3867
+ # CLI overrides for the depth profile's result caps (issue #716). Stashed on
3868
+ # config so pipeline.run() can apply them without widening its signature; the
3869
+ # comparison path inherits them via `entity_config = dict(config)`.
3870
+ if args.max_results is not None:
3871
+ config["_max_results"] = args.max_results
3872
+ if args.max_per_source is not None:
3873
+ config["_max_per_source"] = args.max_per_source
3874
+ if args.max_source_fetches is not None:
3875
+ config["_max_source_fetches"] = args.max_source_fetches
1081
3876
  try:
1082
3877
  x_related = [h.strip() for h in args.x_related.split(",") if h.strip()] if args.x_related else None
1083
3878
  subreddits = [s.strip().removeprefix("r/") for s in args.subreddits.split(",") if s.strip()] if args.subreddits else None
@@ -1092,7 +3887,8 @@ def main() -> int:
1092
3887
  plan_str = args.plan
1093
3888
  if os.path.isfile(plan_str):
1094
3889
  try:
1095
- plan_str = open(plan_str, encoding="utf-8").read()
3890
+ with open(plan_str, encoding="utf-8") as f:
3891
+ plan_str = f.read()
1096
3892
  except (OSError, UnicodeDecodeError) as exc:
1097
3893
  sys.stderr.write(f"[Planner] Cannot read --plan file: {exc}\n")
1098
3894
  raise SystemExit(2)
@@ -1104,20 +3900,57 @@ def main() -> int:
1104
3900
  # and burning a paid run the user did not ask for. Mirrors the
1105
3901
  # --plan file-read branch above and parse_competitors_plan.
1106
3902
  raise SystemExit(2)
3903
+ from lib import planner as _plan_validator
3904
+ try:
3905
+ _plan_validator.validate_external_plan(external_plan)
3906
+ except ValueError as exc:
3907
+ sys.stderr.write(f"[Planner] Invalid --plan schema: {exc}.\n")
3908
+ raise SystemExit(2)
1107
3909
 
1108
3910
  # Auto-resolve: use web search to discover subreddits/handles before planning.
1109
3911
  # This is the engine-side equivalent of SKILL.md Steps 0.55/0.75 for platforms
1110
3912
  # without WebSearch (OpenClaw, Codex, raw CLI).
1111
3913
  repos_from_auto_resolve = False
3914
+ trustpilot_domain_is_hint = False
3915
+ # Resolve automatically for entity-shaped topics even without the flag.
3916
+ # A person or company topic whose handle the user did not supply is the
3917
+ # case where first-party evidence is hardest to protect: the handle is
3918
+ # absent from the topic and may never appear in retrieved mentions, so
3919
+ # nothing downstream can identify the subject's own posts. One web
3920
+ # search closes that. If it returns nothing, pipeline.run skips the X
3921
+ # relevance floor entirely — a noisier report beats losing evidence.
3922
+ # Skipped when a handle was already supplied, when an external plan
3923
+ # owns resolution, or in mock runs.
3924
+ auto_resolve_topic = topic
3925
+ if args.competitors_list is None and (args.competitors is None or comp_plan):
3926
+ from lib import planner as _planner
3927
+ vs_entities = _planner._comparison_entities(topic, uncapped=True)
3928
+ if len(vs_entities) >= 2:
3929
+ auto_resolve_topic = vs_entities[0]
3930
+ if (
3931
+ not args.auto_resolve
3932
+ and not external_plan
3933
+ and not args.x_handle
3934
+ and not args.mock
3935
+ and _looks_like_entity_topic(auto_resolve_topic)
3936
+ ):
3937
+ args.auto_resolve = True
3938
+ sys.stderr.write(
3939
+ "[AutoResolve] entity-shaped topic with no --x-handle; "
3940
+ "resolving the subject's handle so its own posts are not pruned\n"
3941
+ )
3942
+
1112
3943
  if args.auto_resolve and not external_plan:
1113
3944
  from lib import resolve
1114
- resolution = resolve.auto_resolve(topic, config)
3945
+ resolution = resolve.auto_resolve(auto_resolve_topic, config)
1115
3946
  if resolution.get("subreddits") and not subreddits:
1116
3947
  subreddits = resolution["subreddits"]
1117
3948
  sys.stderr.write(f"[AutoResolve] Subreddits: {', '.join(subreddits)}\n")
1118
3949
  if resolution.get("x_handle") and not args.x_handle:
1119
3950
  args.x_handle = resolution["x_handle"]
1120
3951
  sys.stderr.write(f"[AutoResolve] X handle: @{args.x_handle}\n")
3952
+ # Empty x_handle is intentional: do not invent a lexical stand-in.
3953
+ # pipeline.run treats an unidentified subject as "skip the X floor".
1121
3954
  if resolution.get("github_user") and not args.github_user:
1122
3955
  args.github_user = resolution["github_user"]
1123
3956
  sys.stderr.write(f"[AutoResolve] GitHub user: @{args.github_user}\n")
@@ -1127,6 +3960,12 @@ def main() -> int:
1127
3960
  # mark so we don't re-canonicalize below and clobber its relevance order.
1128
3961
  repos_from_auto_resolve = True
1129
3962
  sys.stderr.write(f"[AutoResolve] GitHub repos: {args.github_repo}\n")
3963
+ if resolution.get("trustpilot_domain") and not args.trustpilot_domain:
3964
+ # Hint provenance matters: only user-set flags are verbatim-final;
3965
+ # a resolved hint retries via the CLI search when it misses.
3966
+ args.trustpilot_domain = resolution["trustpilot_domain"]
3967
+ trustpilot_domain_is_hint = True
3968
+ sys.stderr.write(f"[AutoResolve] Trustpilot domain: {args.trustpilot_domain} (hint)\n")
1130
3969
  if resolution.get("context"):
1131
3970
  # Inject context into external_plan metadata for the planner to use
1132
3971
  if not external_plan:
@@ -1137,6 +3976,20 @@ def main() -> int:
1137
3976
 
1138
3977
  github_user = args.github_user.lstrip("@").lower() if args.github_user else None
1139
3978
  github_repos = [r.strip() for r in args.github_repo.split(",") if r.strip() and "/" in r.strip()] if args.github_repo else None
3979
+ trustpilot_domain = args.trustpilot_domain.strip() if args.trustpilot_domain else None
3980
+
3981
+ comp_enabled, comp_count, comp_explicit = resolve_competitors_args(args)
3982
+ # comp_plan was parsed, and its per-entity envelopes validated, before
3983
+ # the report-cache lookup above.
3984
+
3985
+ # Plan-level trustpilot_domain pins are the same user intent as the CLI
3986
+ # flag (already activated above). Auto-resolve hints must not activate.
3987
+ if plan_has_explicit_trustpilot_domain(comp_plan):
3988
+ requested_sources = activate_trustpilot_for_explicit_domain(
3989
+ config,
3990
+ requested_sources,
3991
+ reason="competitors-plan trustpilot_domain",
3992
+ )
1140
3993
 
1141
3994
  # Only canonicalize when repos came from a user-supplied --github-repo flag.
1142
3995
  # When repos_from_auto_resolve is True, auto_resolve already ran
@@ -1152,20 +4005,6 @@ def main() -> int:
1152
4005
  f"{','.join(original_github_repos)} -> {','.join(github_repos)}\n"
1153
4006
  )
1154
4007
 
1155
- # --deep-research: auto-enable perplexity source and set deep flag
1156
- if args.deep_research:
1157
- if not (config.get("PERPLEXITY_API_KEY") or config.get("OPENROUTER_API_KEY")):
1158
- print("Error: --deep-research requires PERPLEXITY_API_KEY or OPENROUTER_API_KEY", file=sys.stderr)
1159
- sys.exit(1)
1160
- config["_deep_research"] = True
1161
- # Auto-enable perplexity in INCLUDE_SOURCES
1162
- include = config.get("INCLUDE_SOURCES") or ""
1163
- if "perplexity" not in include.lower():
1164
- config["INCLUDE_SOURCES"] = f"{include},perplexity" if include else "perplexity"
1165
-
1166
- comp_enabled, comp_count, comp_explicit = resolve_competitors_args(args)
1167
- comp_plan = parse_competitors_plan(args.competitors_plan)
1168
-
1169
4008
  # Polymarket disambiguation: if user passed --polymarket-keywords,
1170
4009
  # store on config so the polymarket adapter can filter matches.
1171
4010
  if args.polymarket_keywords:
@@ -1177,23 +4016,82 @@ def main() -> int:
1177
4016
  if keywords:
1178
4017
  config["_polymarket_keywords"] = keywords
1179
4018
 
1180
- # vs-mode: if the topic string contains " vs " / " versus " and the
1181
- # planner can split it into >=2 entities, route through the same
1182
- # N-pass fanout path as --competitors. The first entity becomes the
1183
- # main topic; remaining entities become the competitor list. User's
1184
- # outer --x-handle / --subreddits apply to the first entity unless
1185
- # --competitors-plan covers it.
1186
- from lib import planner as _planner
1187
- vs_entities = _planner._comparison_entities(topic)
1188
- if len(vs_entities) >= 2 and not comp_enabled:
1189
- topic = vs_entities[0]
1190
- comp_enabled = True
1191
- comp_count = len(vs_entities) - 1
1192
- comp_explicit = vs_entities[1:]
4019
+ # Product keyword for the amazon source. Carried on config rather than
4020
+ # threaded through the run signature (the _polymarket_keywords idiom):
4021
+ # it is one optional string consumed in exactly two places.
4022
+ if getattr(args, "amazon_query", None):
4023
+ config["_amazon_query"] = args.amazon_query.strip()
4024
+ # Unlike --trustpilot-domain, this flag deliberately does NOT
4025
+ # auto-activate its source: the lane spends metered credits, so
4026
+ # turning it on stays an explicit request. But silence is the
4027
+ # wrong failure mode -- a model that resolves the keyword and
4028
+ # forgets the --search token would otherwise get no signal at
4029
+ # all that the flag did nothing.
4030
+ _amazon_requested = (
4031
+ (requested_sources and "amazon" in requested_sources)
4032
+ or "amazon" in str(config.get("INCLUDE_SOURCES") or "").lower()
4033
+ )
4034
+ if not _amazon_requested:
4035
+ sys.stderr.write(
4036
+ "[Amazon] --amazon-query was set but the amazon source was not "
4037
+ "requested; add it to --search (e.g. --search reddit,x,amazon) "
4038
+ "or set INCLUDE_SOURCES=amazon. Ignoring the keyword.\n"
4039
+ )
4040
+
4041
+ # Advertiser page override for the meta_ads source. Same shape as
4042
+ # --amazon-query (config-carried, warn-not-activate) and for the same
4043
+ # reason: the lane spends metered credits per call.
4044
+ if getattr(args, "meta_ads_page", None):
4045
+ page_id = parse_meta_ads_page(args.meta_ads_page)
4046
+ if not page_id:
4047
+ sys.stderr.write(
4048
+ "[Meta Ads] --meta-ads-page must be a numeric Ad Library page id "
4049
+ "or an Ad Library URL containing view_all_page_id; a facebook.com "
4050
+ "vanity URL is not a page id. Ignoring the override.\n"
4051
+ )
4052
+ else:
4053
+ config["_meta_ads_page"] = page_id
4054
+ _meta_ads_requested = (
4055
+ (requested_sources and "meta_ads" in requested_sources)
4056
+ or "meta_ads" in str(config.get("INCLUDE_SOURCES") or "").lower()
4057
+ )
4058
+ if not _meta_ads_requested:
4059
+ sys.stderr.write(
4060
+ "[Meta Ads] --meta-ads-page was set but the meta_ads source "
4061
+ "was not requested; add it to --search (e.g. --search "
4062
+ "reddit,x,meta_ads) or set INCLUDE_SOURCES=meta_ads. "
4063
+ "Ignoring the page.\n"
4064
+ )
4065
+
4066
+ # vs-mode / plan routing: split a vs-topic into main + peers unless
4067
+ # discover-N or an explicit --competitors-list already decided who runs.
4068
+ topic, comp_enabled, comp_count, comp_explicit = apply_vs_competitor_routing(
4069
+ topic,
4070
+ competitors_flag=args.competitors,
4071
+ comp_enabled=comp_enabled,
4072
+ comp_count=comp_count,
4073
+ comp_explicit=comp_explicit,
4074
+ comp_plan=comp_plan,
4075
+ )
4076
+ if comp_enabled:
4077
+ config["_perplexity_paid_budget"] = pipeline.PaidSourceBudget(
4078
+ owner=topic,
4079
+ )
4080
+
4081
+ # Plan alone with zero peers (empty/invalid JSON object, or all entries
4082
+ # skipped) must not fall through to discover-N with a misleading abort.
4083
+ if (
4084
+ comp_enabled
4085
+ and not comp_explicit
4086
+ and args.competitors is None
4087
+ and args.competitors_plan
4088
+ ):
1193
4089
  sys.stderr.write(
1194
- f"[Competitors] vs-mode: routing to N-pass fanout: "
1195
- f"{' vs '.join(vs_entities)}\n"
4090
+ "[Competitors] --competitors-plan has no usable peer entries "
4091
+ "(and the topic is not a vs-comparison). Pass a non-empty plan, "
4092
+ "a vs-topic, --competitors-list, or --competitors N.\n"
1196
4093
  )
4094
+ return 2
1197
4095
 
1198
4096
  # Dedicated subs ride the config dict (already threaded to every source
1199
4097
  # fetch) so the keyless Reddit path can pull them floor-exempt without
@@ -1220,8 +4118,17 @@ def main() -> int:
1220
4118
  as_of_date=args.as_of_date,
1221
4119
  github_user=github_user,
1222
4120
  github_repos=github_repos,
4121
+ trustpilot_domain=trustpilot_domain,
4122
+ trustpilot_domain_is_hint=trustpilot_domain_is_hint,
1223
4123
  internal_subrun=comp_enabled,
1224
4124
  hiring_signals_mode=args.hiring_signals,
4125
+ save_dir=args.save_dir,
4126
+ corpus_dirs=args.corpus,
4127
+ corpus_all_time=args.corpus_all_time,
4128
+ x_posts=(
4129
+ comp_plan.get(topic.strip().lower(), {}).get("_x_envelope")
4130
+ if comp_enabled else x_posts_envelope
4131
+ ),
1225
4132
  )
1226
4133
  r.artifacts["resolved"] = {
1227
4134
  "entity": topic,
@@ -1229,6 +4136,7 @@ def main() -> int:
1229
4136
  "subreddits": list(subreddits or []),
1230
4137
  "github_user": (github_user or ""),
1231
4138
  "github_repos": list(github_repos or []),
4139
+ "trustpilot_domain": (trustpilot_domain or ""),
1232
4140
  "context": config.get("_auto_resolve_context", "") or "",
1233
4141
  }
1234
4142
  return r
@@ -1253,7 +4161,7 @@ def main() -> int:
1253
4161
  " 3. Re-invoke: /last30days '{topic} vs {peer1} vs {peer2}' "
1254
4162
  "--competitors-plan '{\"Peer1\":{\"x_handle\":\"h1\",\"subreddits\":"
1255
4163
  "[\"s1\"],...},\"Peer2\":{...}}'.\n"
1256
- "See SKILL.md 'Competitor mode' for the full protocol.\n"
4164
+ "See the skill's references/competitors.md for the full protocol.\n"
1257
4165
  "\n"
1258
4166
  "HEADLESS / CRON PATH (no hosting model available): set "
1259
4167
  "BRAVE_API_KEY / EXA_API_KEY / SERPER_API_KEY / PARALLEL_API_KEY / "
@@ -1274,15 +4182,55 @@ def main() -> int:
1274
4182
  )
1275
4183
  return 2
1276
4184
 
4185
+ # run_competitor_fanout keys its results by label, so two
4186
+ # submissions sharing one collapse to a single report while the
4187
+ # returned list still carries two entries. That yields a
4188
+ # comparison of an entity against itself, and it hides a failed
4189
+ # main topic from the survivor check below: the duplicate peer's
4190
+ # report answers for the label the main run was supposed to fill.
4191
+ distinct_peers: list[str] = []
4192
+ claimed_labels = {comparison_label_key(topic)}
4193
+ for peer in discovered:
4194
+ key = comparison_label_key(peer)
4195
+ if key in claimed_labels:
4196
+ sys.stderr.write(
4197
+ f"[Competitors] Dropping {peer!r}: duplicates the main "
4198
+ "topic or an earlier peer.\n"
4199
+ )
4200
+ continue
4201
+ claimed_labels.add(key)
4202
+ distinct_peers.append(peer)
4203
+ if not distinct_peers:
4204
+ sys.stderr.write(
4205
+ f"[Competitors] No peer distinct from {topic!r} remains; "
4206
+ "there is nothing to compare against. Pass "
4207
+ "--competitors-list with distinct entities.\n"
4208
+ )
4209
+ return 2
4210
+ discovered = distinct_peers
4211
+
1277
4212
  sys.stderr.write(
1278
4213
  f"[Competitors] Comparing: {topic} vs " + " vs ".join(discovered) + "\n"
1279
4214
  )
1280
4215
 
1281
4216
  def _competitor_runner(entity: str) -> schema.Report:
1282
- # Deep-copy config so per-entity auto_resolve context does not
1283
- # leak across sub-runs. Each sub-run writes its own
1284
- # `_auto_resolve_context` into its local config copy.
4217
+ # Resolution belongs to each entity, not the shared command.
1285
4218
  entity_config = dict(config)
4219
+ entity_config.pop("_auto_resolve_context", None)
4220
+ # The Amazon keyword is entity-SPECIFIC, unlike the depth caps
4221
+ # this shallow copy exists to inherit. Leaving the main topic's
4222
+ # keyword in place would search Weber SKUs for a Traeger peer,
4223
+ # render a rival's products as that peer's buyer evidence, and
4224
+ # multiply the metered spend by the number of entities. Drop it
4225
+ # so each peer derives its own keyword from its own topic; a
4226
+ # per-entity keyword can ride in the --competitors-plan entry.
4227
+ entity_config.pop("_amazon_query", None)
4228
+ # dev.to tags describe the main topic; a peer falls back to
4229
+ # the slug of its own name instead of a rival's tags.
4230
+ entity_config.pop("_devto_tags", None)
4231
+ # An advertiser page is per-entity state by definition: left in
4232
+ # place it would render one brand's ads as every peer's.
4233
+ entity_config.pop("_meta_ads_page", None)
1286
4234
  plan_entry = comp_plan.get(entity.strip().lower(), {})
1287
4235
  resolved = {
1288
4236
  "entity": entity,
@@ -1290,6 +4238,7 @@ def main() -> int:
1290
4238
  "subreddits": [],
1291
4239
  "github_user": "",
1292
4240
  "github_repos": [],
4241
+ "trustpilot_domain": "",
1293
4242
  "context": "",
1294
4243
  }
1295
4244
  # Skip engine-internal auto_resolve when the hosting model
@@ -1316,6 +4265,7 @@ def main() -> int:
1316
4265
  resolved["subreddits"] = list(r.get("subreddits") or [])
1317
4266
  resolved["github_user"] = r.get("github_user", "") or ""
1318
4267
  resolved["github_repos"] = list(r.get("github_repos") or [])
4268
+ resolved["trustpilot_domain"] = r.get("trustpilot_domain", "") or ""
1319
4269
  resolved["context"] = r.get("context", "") or ""
1320
4270
  kwargs = subrun_kwargs_for(entity, plan_entry, resolved=resolved)
1321
4271
  # Record effective per-entity targeting for the Resolved block.
@@ -1325,6 +4275,7 @@ def main() -> int:
1325
4275
  "subreddits": kwargs["subreddits"] or [],
1326
4276
  "github_user": kwargs["github_user"] or "",
1327
4277
  "github_repos": kwargs["github_repos"] or [],
4278
+ "trustpilot_domain": kwargs["trustpilot_domain"] or "",
1328
4279
  "context": kwargs["_context"],
1329
4280
  }
1330
4281
  if kwargs["_context"]:
@@ -1347,11 +4298,17 @@ def main() -> int:
1347
4298
  subreddits=kwargs["subreddits"],
1348
4299
  github_user=kwargs["github_user"],
1349
4300
  github_repos=kwargs["github_repos"],
4301
+ trustpilot_domain=kwargs["trustpilot_domain"],
4302
+ trustpilot_domain_is_hint=kwargs["_trustpilot_domain_is_hint"],
1350
4303
  web_backend=args.web_backend,
1351
4304
  lookback_days=args.lookback_days,
1352
4305
  as_of_date=args.as_of_date,
1353
4306
  hiring_signals_mode=args.hiring_signals,
1354
4307
  internal_subrun=True,
4308
+ save_dir=args.save_dir,
4309
+ corpus_dirs=args.corpus,
4310
+ corpus_all_time=args.corpus_all_time,
4311
+ x_posts=plan_entry.get("_x_envelope"),
1355
4312
  )
1356
4313
  report.artifacts["resolved"] = resolved_effective
1357
4314
  return report
@@ -1362,6 +4319,25 @@ def main() -> int:
1362
4319
  competitors=discovered,
1363
4320
  competitor_runner=_competitor_runner,
1364
4321
  )
4322
+ # run_competitor_fanout drops a failed sub-run from the list, and
4323
+ # the render takes entity_reports[0] as the comparison's subject.
4324
+ # Without this check, a main topic that raised while >=2 peers
4325
+ # succeeded silently promoted a competitor to be the subject: the
4326
+ # report was headed by that peer, saved under its slug, and the
4327
+ # topic the user actually asked about went unmentioned.
4328
+ survived = {label for label, _ in entity_reports}
4329
+ dropped = [
4330
+ label for label in (topic, *discovered) if label not in survived
4331
+ ]
4332
+ if topic not in survived:
4333
+ progress.end_processing()
4334
+ sys.stderr.write(
4335
+ f"[Competitors] The main topic {topic!r} failed; "
4336
+ f"{len(entity_reports)} competitor sub-run(s) survived. "
4337
+ "Refusing to render a comparison headed by a competitor. "
4338
+ "Check the warnings above.\n"
4339
+ )
4340
+ return 1
1365
4341
  if len(entity_reports) < 2:
1366
4342
  progress.end_processing()
1367
4343
  sys.stderr.write(
@@ -1371,6 +4347,14 @@ def main() -> int:
1371
4347
  )
1372
4348
  return 1
1373
4349
  report = entity_reports[0][1]
4350
+ if dropped:
4351
+ # A narrower comparison than the user asked for is a result
4352
+ # they need to see, not a silent substitution.
4353
+ report.warnings.append(
4354
+ "Comparison is incomplete: "
4355
+ f"{len(dropped)} of {len(discovered) + 1} entities failed and "
4356
+ f"were dropped ({', '.join(dropped)})."
4357
+ )
1374
4358
  else:
1375
4359
  entity_reports = None
1376
4360
  report = _main_runner()
@@ -1378,11 +4362,17 @@ def main() -> int:
1378
4362
  progress.end_processing()
1379
4363
  progress.show_error(str(exc))
1380
4364
  raise
4365
+ if _freshness_enabled(args, config):
4366
+ _verify_report_set(report, entity_reports, allow_network=not args.mock)
4367
+
1381
4368
  _show_runtime_ui(
1382
4369
  report, progress, diag,
1383
4370
  suppress_web_promo=bool(external_plan or comp_plan),
1384
4371
  )
1385
- _write_last_run(original_topic, report, entity_reports=entity_reports)
4372
+ _write_last_run(
4373
+ original_topic, report, entity_reports=entity_reports,
4374
+ x_envelope_sha256=_x_envelope_digest(x_posts_envelope, comp_plan),
4375
+ )
1386
4376
  # LAST30DAYS_STORE env var = persistence default-on. Read both os.environ
1387
4377
  # (for shell-exported users) and config (for users who set it in
1388
4378
  # ~/.config/last30days/.env, which env.py loads but does not propagate
@@ -1394,7 +4384,7 @@ def main() -> int:
1394
4384
  or ""
1395
4385
  ).lower()
1396
4386
  if args.store or _store_env in ("1", "true", "yes"):
1397
- counts = persist_report(report)
4387
+ counts = persist_report(report, store_db=_scoped_store_db(args))
1398
4388
  sys.stderr.write(
1399
4389
  f"[last30days] Stored {counts['new']} new, {counts['updated']} updated findings\n"
1400
4390
  )
@@ -1406,39 +4396,9 @@ def main() -> int:
1406
4396
  try:
1407
4397
  from lib import quality_nudge
1408
4398
  from lib import youtube_yt as _youtube_yt
1409
- # Populate transcript-fetch ratio so quality_nudge can detect the
1410
- # degraded-YouTube failure mode (videos returned but transcripts
1411
- # silently failed - typically a stale yt-dlp binary).
1412
- youtube_items = report.items_by_source.get("youtube") or []
1413
- _yt_fetch_stats = _youtube_yt.get_transcript_fetch_stats()
1414
- instagram_items = report.items_by_source.get("instagram") or []
1415
- research_results = {
1416
- "youtube_videos_count": len(youtube_items),
1417
- "youtube_transcripts_count": sum(
1418
- 1 for it in youtube_items
1419
- if (it.metadata.get("transcript_highlights") or it.metadata.get("transcript_snippet"))
1420
- ),
1421
- "youtube_error": report.errors_by_source.get("youtube"),
1422
- "x_error": report.errors_by_source.get("x"),
1423
- # Captions-disabled videos can never produce a transcript regardless
1424
- # of yt-dlp version; subtract them from the degraded-ratio
1425
- # denominator so a single uploader-disabled video does not trip the
1426
- # "stale yt-dlp" nudge.
1427
- "youtube_captions_disabled_count": sum(
1428
- 1 for it in youtube_items if it.metadata.get("captions_disabled")
1429
- ),
1430
- # Actual yt-dlp fetch outcomes for this run. The counts above are
1431
- # computed from post-pruning items, so they can't tell "fetches
1432
- # failed (stale binary)" from "fetches succeeded but the videos
1433
- # were pruned downstream"; the latter was producing false
1434
- # stale-yt-dlp nudges (#531).
1435
- "youtube_transcript_fetch_attempts": _yt_fetch_stats["attempts"],
1436
- "youtube_transcript_fetch_failures": _yt_fetch_stats["failures"],
1437
- # Track Instagram returned-zero-items so quality_nudge can detect
1438
- # the silent-failure case (SC configured but the v2 reels endpoint
1439
- # 500'd through both the original query and the hashtag retry).
1440
- "instagram_items_count": len(instagram_items),
1441
- }
4399
+ research_results = _quality_research_results(
4400
+ report, diag, _youtube_yt.get_transcript_fetch_stats()
4401
+ )
1442
4402
  quality = quality_nudge.compute_quality_score(config, research_results)
1443
4403
  if quality.get("nudge_text"):
1444
4404
  sys.stderr.write(f"\n{quality['nudge_text']}\n")
@@ -1460,8 +4420,15 @@ def main() -> int:
1460
4420
  )
1461
4421
  report.artifacts["pre_research_flags_present"] = pre_research_flags_present
1462
4422
 
1463
- return _render_save_and_print(args, report, entity_reports, synthesis_md, config)
4423
+ exit_code = _render_save_and_print(args, report, entity_reports, synthesis_md, config)
4424
+ if args.emit in {"compact", "md", "brief"}:
4425
+ x_omission = _optional_x_omission_text(diag, requested_sources)
4426
+ if x_omission:
4427
+ sys.stderr.write(f"\n{x_omission}\n")
4428
+ sys.stderr.flush()
4429
+ return exit_code
1464
4430
 
1465
4431
 
1466
4432
  if __name__ == "__main__":
4433
+ _install_sigterm_handler()
1467
4434
  raise SystemExit(main())