luckiest-co 1.0.46 → 1.0.48

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 (266) 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/pricing/CHANGELOG.md +5 -0
  81. package/skills/pricing/SKILL.md +4 -4
  82. package/skills/pricing/evals/evals.json +12 -0
  83. package/skills/pricing/references/research-methods.md +8 -4
  84. package/skills/programmatic-seo/CHANGELOG.md +5 -0
  85. package/skills/programmatic-seo/SKILL.md +8 -4
  86. package/skills/programmatic-seo/evals/evals.json +13 -0
  87. package/skills/programmatic-seo/references/implementation-platforms.md +37 -0
  88. package/skills/prospecting/CHANGELOG.md +7 -0
  89. package/skills/prospecting/SKILL.md +27 -13
  90. package/skills/prospecting/evals/evals.json +41 -0
  91. package/skills/prospecting/references/account-research.md +59 -0
  92. package/skills/prospecting/references/b2b-prospecting.md +1 -1
  93. package/skills/prospecting/references/compliance.md +44 -6
  94. package/skills/prospecting/references/data-sources.md +13 -15
  95. package/skills/prospecting/references/enrichment-playbook.md +61 -0
  96. package/skills/prospecting/references/saas-prospecting.md +1 -1
  97. package/skills/prospecting/references/signal-plays.md +99 -0
  98. package/skills/prospecting/references/sourcing-and-enrichment.md +89 -0
  99. package/skills/referrals/CHANGELOG.md +5 -0
  100. package/skills/referrals/SKILL.md +10 -4
  101. package/skills/referrals/evals/evals.json +99 -0
  102. package/skills/referrals/references/affiliate-operations.md +64 -0
  103. package/skills/referrals/references/affiliate-programs.md +2 -2
  104. package/skills/referrals/references/program-examples.md +14 -6
  105. package/skills/referrals/references/review-platform-rules.md +36 -0
  106. package/skills/referrals/references/review-program-operations.md +62 -0
  107. package/skills/referrals/references/review-programs.md +96 -0
  108. package/skills/revops/CHANGELOG.md +5 -0
  109. package/skills/revops/SKILL.md +10 -6
  110. package/skills/revops/evals/evals.json +13 -0
  111. package/skills/revops/references/automation-playbooks.md +1 -1
  112. package/skills/revops/references/inbound-routing.md +43 -0
  113. package/skills/revops/references/lifecycle-definitions.md +31 -0
  114. package/skills/sales-enablement/CHANGELOG.md +5 -0
  115. package/skills/sales-enablement/SKILL.md +6 -4
  116. package/skills/sales-enablement/evals/evals.json +14 -0
  117. package/skills/sales-enablement/references/cold-call-scripts.md +68 -0
  118. package/skills/schema/CHANGELOG.md +5 -0
  119. package/skills/schema/SKILL.md +20 -8
  120. package/skills/schema/evals/evals.json +40 -0
  121. package/skills/schema/references/schema-examples.md +9 -15
  122. package/skills/seo-audit/CHANGELOG.md +5 -0
  123. package/skills/seo-audit/SKILL.md +10 -16
  124. package/skills/seo-audit/evals/evals.json +68 -0
  125. package/skills/seo-audit/references/international-seo.md +9 -11
  126. package/skills/seo-audit/references/local-seo-pages-and-measurement.md +39 -0
  127. package/skills/seo-audit/references/local-seo-profile-and-citations.md +41 -0
  128. package/skills/seo-audit/references/local-seo.md +86 -0
  129. package/skills/seo-audit/references/rankings-push.md +62 -0
  130. package/skills/seo-audit/references/title-tags.md +89 -0
  131. package/skills/signup/CHANGELOG.md +5 -0
  132. package/skills/signup/SKILL.md +7 -3
  133. package/skills/site-architecture/CHANGELOG.md +5 -0
  134. package/skills/site-architecture/SKILL.md +5 -4
  135. package/skills/site-architecture/evals/evals.json +25 -0
  136. package/skills/site-architecture/references/platforms-and-migration.md +84 -0
  137. package/skills/social/CHANGELOG.md +5 -0
  138. package/skills/social/SKILL.md +4 -4
  139. package/skills/trends/.skillignore +3 -0
  140. package/skills/trends/CHANGELOG.md +20 -0
  141. package/skills/trends/SKILL.md +12 -8
  142. package/skills/trends/references/agent-mode.md +36 -0
  143. package/skills/trends/references/category-peers.md +38 -0
  144. package/skills/trends/references/comparison.md +227 -0
  145. package/skills/trends/references/competitors.md +29 -0
  146. package/skills/trends/references/discovery.md +90 -0
  147. package/skills/trends/references/engine-contract.md +111 -1889
  148. package/skills/trends/references/followups.md +112 -0
  149. package/skills/trends/references/grok-bot-x.md +55 -0
  150. package/skills/trends/references/hiring-signals.md +19 -0
  151. package/skills/trends/references/library-queue.md +33 -0
  152. package/skills/trends/references/recommendations.md +86 -0
  153. package/skills/trends/references/research-runbook.md +781 -0
  154. package/skills/trends/references/runtime.md +165 -0
  155. package/skills/trends/references/save-html-brief.md +39 -20
  156. package/skills/trends/references/setup-wizard.md +253 -0
  157. package/skills/trends/references/synthesis.md +289 -0
  158. package/skills/trends/scripts/box_chrome_login.py +209 -0
  159. package/skills/trends/scripts/briefing.py +9 -17
  160. package/skills/trends/scripts/last30days.py +3247 -280
  161. package/skills/trends/scripts/lib/agentcookie.py +196 -0
  162. package/skills/trends/scripts/lib/amazon.py +877 -0
  163. package/skills/trends/scripts/lib/arxiv.py +42 -8
  164. package/skills/trends/scripts/lib/backends.py +769 -0
  165. package/skills/trends/scripts/lib/bird_x.py +340 -57
  166. package/skills/trends/scripts/lib/bluesky.py +84 -10
  167. package/skills/trends/scripts/lib/bounded_get.py +156 -0
  168. package/skills/trends/scripts/lib/brightdata.py +291 -0
  169. package/skills/trends/scripts/lib/categories.py +7 -1
  170. package/skills/trends/scripts/lib/chrome_cdp.py +455 -0
  171. package/skills/trends/scripts/lib/chrome_cookies.py +132 -39
  172. package/skills/trends/scripts/lib/cluster.py +31 -54
  173. package/skills/trends/scripts/lib/competitors.py +10 -1
  174. package/skills/trends/scripts/lib/cookie_extract.py +221 -44
  175. package/skills/trends/scripts/lib/cookie_paths.py +27 -0
  176. package/skills/trends/scripts/lib/corpus.py +439 -0
  177. package/skills/trends/scripts/lib/devto.py +313 -0
  178. package/skills/trends/scripts/lib/discovery_handoff.py +988 -0
  179. package/skills/trends/scripts/lib/doctor.py +2166 -0
  180. package/skills/trends/scripts/lib/dripstack.py +203 -0
  181. package/skills/trends/scripts/lib/entity_extract.py +37 -0
  182. package/skills/trends/scripts/lib/env.py +1092 -138
  183. package/skills/trends/scripts/lib/fanout.py +11 -1
  184. package/skills/trends/scripts/lib/feed.py +61 -0
  185. package/skills/trends/scripts/lib/fetch_memo.py +135 -0
  186. package/skills/trends/scripts/lib/freshness.py +566 -0
  187. package/skills/trends/scripts/lib/fusion.py +314 -28
  188. package/skills/trends/scripts/lib/github.py +438 -24
  189. package/skills/trends/scripts/lib/grok_x.py +1237 -0
  190. package/skills/trends/scripts/lib/grounding.py +129 -11
  191. package/skills/trends/scripts/lib/hackernews.py +61 -4
  192. package/skills/trends/scripts/lib/health.py +373 -0
  193. package/skills/trends/scripts/lib/hosted.py +333 -0
  194. package/skills/trends/scripts/lib/html_publish.py +34 -0
  195. package/skills/trends/scripts/lib/html_render.py +169 -3
  196. package/skills/trends/scripts/lib/http.py +1215 -70
  197. package/skills/trends/scripts/lib/instagram.py +132 -9
  198. package/skills/trends/scripts/lib/jobs.py +4 -2
  199. package/skills/trends/scripts/lib/library.py +308 -0
  200. package/skills/trends/scripts/lib/library_index.py +477 -0
  201. package/skills/trends/scripts/lib/linkedin.py +271 -24
  202. package/skills/trends/scripts/lib/log.py +5 -2
  203. package/skills/trends/scripts/lib/meta_ads.py +982 -0
  204. package/skills/trends/scripts/lib/normalize.py +382 -28
  205. package/skills/trends/scripts/lib/parallel_mcp.py +234 -0
  206. package/skills/trends/scripts/lib/permission_preflight.py +42 -3
  207. package/skills/trends/scripts/lib/perplexity.py +1080 -468
  208. package/skills/trends/scripts/lib/pipeline.py +5079 -211
  209. package/skills/trends/scripts/lib/planner.py +396 -53
  210. package/skills/trends/scripts/lib/polymarket.py +285 -23
  211. package/skills/trends/scripts/lib/prescriptions.py +432 -0
  212. package/skills/trends/scripts/lib/private_files.py +21 -0
  213. package/skills/trends/scripts/lib/providers.py +231 -10
  214. package/skills/trends/scripts/lib/quality_nudge.py +122 -37
  215. package/skills/trends/scripts/lib/reddit.py +177 -12
  216. package/skills/trends/scripts/lib/reddit_arctic.py +146 -5
  217. package/skills/trends/scripts/lib/reddit_keyless.py +121 -60
  218. package/skills/trends/scripts/lib/reddit_listing.py +215 -14
  219. package/skills/trends/scripts/lib/reddit_public.py +12 -13
  220. package/skills/trends/scripts/lib/reddit_search.py +304 -0
  221. package/skills/trends/scripts/lib/reddit_shreddit.py +51 -7
  222. package/skills/trends/scripts/lib/registers.py +134 -0
  223. package/skills/trends/scripts/lib/relevance.py +17 -0
  224. package/skills/trends/scripts/lib/render.py +2161 -271
  225. package/skills/trends/scripts/lib/rerank.py +623 -55
  226. package/skills/trends/scripts/lib/resolve.py +50 -1
  227. package/skills/trends/scripts/lib/safari_cookies.py +42 -15
  228. package/skills/trends/scripts/lib/schema.py +782 -1
  229. package/skills/trends/scripts/lib/setup_wizard.py +779 -120
  230. package/skills/trends/scripts/lib/signals.py +206 -7
  231. package/skills/trends/scripts/lib/stocktwits.py +374 -0
  232. package/skills/trends/scripts/lib/subproc.py +307 -53
  233. package/skills/trends/scripts/lib/techmeme.py +89 -64
  234. package/skills/trends/scripts/lib/telegram.py +317 -0
  235. package/skills/trends/scripts/lib/tiktok.py +13 -9
  236. package/skills/trends/scripts/lib/topic_shape.py +340 -0
  237. package/skills/trends/scripts/lib/transcribe.py +12 -3
  238. package/skills/trends/scripts/lib/trustpilot.py +256 -5
  239. package/skills/trends/scripts/lib/truthsocial.py +9 -1
  240. package/skills/trends/scripts/lib/ui.py +6 -0
  241. package/skills/trends/scripts/lib/usage.py +132 -0
  242. package/skills/trends/scripts/lib/web_search_keyless.py +63 -4
  243. package/skills/trends/scripts/lib/windows_job.py +138 -0
  244. package/skills/trends/scripts/lib/x_api.py +824 -0
  245. package/skills/trends/scripts/lib/x_envelope.py +733 -0
  246. package/skills/trends/scripts/lib/x_judge.py +292 -0
  247. package/skills/trends/scripts/lib/xai_x.py +26 -3
  248. package/skills/trends/scripts/lib/xquik.py +89 -15
  249. package/skills/trends/scripts/lib/xurl_x.py +284 -91
  250. package/skills/trends/scripts/lib/youtube_yt.py +570 -119
  251. package/skills/trends/scripts/setup-keychain.sh +12 -4
  252. package/skills/trends/scripts/setup-pass.sh +3 -0
  253. package/skills/trends/scripts/store.py +444 -50
  254. package/skills/trends/scripts/watchlist.py +38 -11
  255. package/skills/trends/scripts/x_lane_parity.py +107 -0
  256. package/skills/video-studio/CHANGELOG.md +8 -0
  257. package/skills/video-studio/SKILL.md +6 -5
  258. package/skills/video-studio/references/styles/product-flight.md +45 -0
  259. package/skills/video-studio/references/styles.md +3 -1
  260. package/skills/video-studio/scripts/qa.mjs +64 -2
  261. package/skills/video-studio/scripts/qa.test.mjs +29 -1
  262. package/skills/video-studio-motion/ATTRIBUTION.md +1 -1
  263. package/skills/video-studio-motion/CHANGELOG.md +4 -0
  264. package/skills/video-studio-motion/SKILL.md +8 -5
  265. package/skills/coder-brainstorming/spec-document-reviewer-prompt.md +0 -49
  266. package/skills/trends/scripts/lib/reddit_rss.py +0 -224
@@ -0,0 +1,988 @@
1
+ """File contracts for the three-command host-judged discovery protocol.
2
+
3
+ Leg 1 (``--discover --nominate-only``) writes the nominations bundle: the
4
+ FULL judge pool, each nomination with its complete seed item set, serialized
5
+ losslessly so leg 2 can recompute floor/velocity/entity-token disambiguation
6
+ exactly as an in-memory run would. Leg 2 (``--discover --judgments <file>``)
7
+ reads host judgments (names/junk/worthiness) bound to the bundle by
8
+ bundle_id. Leg 3 (``--discover --finalize [--angles <file>]``) applies
9
+ host-written content angles.
10
+
11
+ This module owns the handoff contracts - bundle writer/reader, judgments
12
+ reader, pending-report reader (the leg-2 output leg 3 finalizes from),
13
+ angles reader - plus the host-facing digest and the post-judgment
14
+ name-collision resolver. Readers are strict at the top level (typed
15
+ ``HandoffContractError``, mapped to exit 2 by the CLI layer) and lenient per
16
+ row: a malformed or omitted row falls back to the bundle's heuristics rather
17
+ than failing the run.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import secrets
24
+ from collections import Counter
25
+ from dataclasses import dataclass, field
26
+ from pathlib import Path
27
+ from typing import Any, Callable, Iterator, Sequence
28
+
29
+ from . import env, log, pipeline, rerank, schema
30
+
31
+
32
+ # How long a nominations bundle stays valid. Deliberately a module constant
33
+ # and NOT the LAST30DAYS_REPORT_CACHE_TTL_SECONDS env knob: a user who
34
+ # lowered the report-cache TTL for drill freshness must not shrink the
35
+ # window a host has to author judgments.
36
+ DISCOVERY_HANDOFF_TTL_SECONDS = 3600.0
37
+
38
+ NOMINATIONS_BUNDLE_FILENAME = "discover-nominations.json"
39
+ PENDING_REPORT_FILENAME = "discover-pending.json"
40
+
41
+ _VALID_TIERS = ("deep", "shallow")
42
+
43
+ _RESWEEP_REMEDY = "Run a fresh `--discover --nominate-only` re-sweep."
44
+
45
+ # Leg-3 remedy: the pending report is leg-2 output, so the first fix is to
46
+ # re-run the resume leg; only when the bundle itself has also gone stale does
47
+ # the whole protocol restart.
48
+ _RESUME_REMEDY = (
49
+ "Re-run the resume leg (`--discover --judgments <file>`), or the full "
50
+ "protocol from `--discover --nominate-only` if the bundle is stale too."
51
+ )
52
+
53
+ # Defensive caps on host-supplied text, ported from the retired engine-judge
54
+ # pass: names become search queries and the /last30days handoff, angles
55
+ # render verbatim on trend cards, so a runaway (or adversarial) value never
56
+ # yields an unbounded string.
57
+ _NAME_MAX_CHARS = 96
58
+ _ANGLE_MAX_CHARS = 200
59
+
60
+ # Unified trailing-punctuation charset for word-boundary truncation: names
61
+ # and angle sentences share it so the strip sets cannot drift.
62
+ _TRUNCATE_STRIP_CHARS = " \"'`.,;:!?-"
63
+
64
+ # Digest evidence caps: the surface the engine judge used to see per
65
+ # nomination (leader title, leader snippet, strongest community comment).
66
+ _DIGEST_TITLE_MAX_CHARS = 220
67
+ _DIGEST_SNIPPET_MAX_CHARS = 420
68
+ _DIGEST_COMMENT_MAX_CHARS = 340
69
+
70
+
71
+ class HandoffContractError(Exception):
72
+ """A handoff file failed its contract: unreadable, invalid JSON, wrong
73
+ shape or schema version, stale, or not bound to the current bundle.
74
+ The CLI layer maps this to exit code 2."""
75
+
76
+ def __init__(self, message: str) -> None:
77
+ super().__init__(message)
78
+ self.message = message
79
+
80
+
81
+ @dataclass(frozen=True)
82
+ class PoolEntry:
83
+ """One judge-pool nomination as handed to the bundle writer (leg 1).
84
+
85
+ ``heuristic_name`` and ``heuristic_junk`` are the deterministic
86
+ topic_shape fallbacks, kept alongside the nomination so leg 2 can fill
87
+ any row the host omitted without re-deriving them.
88
+ """
89
+
90
+ nomination: pipeline.Nomination
91
+ cluster_id: str
92
+ heuristic_name: str
93
+ heuristic_junk: bool
94
+
95
+
96
+ @dataclass(frozen=True)
97
+ class BundleNomination:
98
+ """One nomination read back from a bundle, with its stable id."""
99
+
100
+ nomination_id: str
101
+ nomination: pipeline.Nomination
102
+ cluster_id: str
103
+ heuristic_name: str
104
+ heuristic_junk: bool
105
+ sources: list[str]
106
+ engagement_by_source: dict[str, dict[str, float | int]] = field(
107
+ default_factory=dict
108
+ )
109
+
110
+
111
+ @dataclass(frozen=True)
112
+ class NominationsBundle:
113
+ """A parsed leg-1 nominations bundle (also returned by the writer).
114
+
115
+ ``source_status`` is the leg-1 sweep's finalized per-source outcome map:
116
+ legs 2 and 3 restore it so degraded sweep coverage survives the protocol
117
+ instead of silently reading as clean. ``mock`` is the writing run's
118
+ provenance - mock-born state must never be finalized by a real run (and
119
+ vice versa); files written before either field existed read as an empty
120
+ map and a real run. ``warnings`` retains informational sweep coverage
121
+ separately from source outcomes; older files read with no warnings."""
122
+
123
+ schema_version: str
124
+ bundle_id: str
125
+ generated_at: str
126
+ from_date: str
127
+ to_date: str
128
+ domain: str
129
+ tier: str
130
+ enrichment_source_boundary: list[str] | None
131
+ requested_sources: list[str] | None
132
+ lookback_days: int
133
+ nominations: list[BundleNomination]
134
+ source_status: dict[str, schema.SourceOutcome] = field(default_factory=dict)
135
+ mock: bool = False
136
+ path: Path | None = None
137
+ warnings: list[str] = field(default_factory=list)
138
+
139
+
140
+ @dataclass(frozen=True)
141
+ class HostJudgment:
142
+ """One host verdict row. ``None`` on any field means the host left it
143
+ absent for that row and the caller falls back to the bundle's heuristic
144
+ value (name/junk) or to no worthiness signal."""
145
+
146
+ name: str | None
147
+ junk: bool | None
148
+ worthiness: int | None
149
+
150
+
151
+ # The per-row-absent marker: what ``judgment_for`` returns for a nomination
152
+ # the host omitted entirely. Every field falls back to the bundle heuristics.
153
+ ROW_ABSENT = HostJudgment(name=None, junk=None, worthiness=None)
154
+
155
+
156
+ @dataclass(frozen=True)
157
+ class HostAngles:
158
+ """One host-written angle row; either field may be absent."""
159
+
160
+ podcast: str | None
161
+ x_article: str | None
162
+
163
+
164
+ @dataclass(frozen=True)
165
+ class PendingReport:
166
+ """A parsed leg-2 pending report: the floored/folded/ranked discovery
167
+ report (as its raw ``schema.to_dict`` payload - leg 3 rebuilds it via
168
+ ``schema.discovery_report_from_dict``) plus the angle inputs keyed by
169
+ surviving nomination id. ``run_ref`` is the leg-2 run identity the
170
+ finalize leg replays into the topic queue so retries stay idempotent."""
171
+
172
+ schema_version: str
173
+ bundle_id: str
174
+ generated_at: str
175
+ run_ref: str
176
+ report: dict[str, Any]
177
+ angle_inputs: dict[str, dict[str, str]]
178
+ # Leg-2 provenance: True when a --mock resume wrote this file. Files
179
+ # written before the flag existed read as real (False).
180
+ mock: bool = False
181
+ path: Path | None = None
182
+
183
+
184
+ def _warn(message: str) -> None:
185
+ log.source_log("Discover", message, tty_only=False)
186
+
187
+
188
+ def handoff_state_dir(
189
+ save_dir: str | Path | None,
190
+ config_dir: Path | None,
191
+ ) -> Path | None:
192
+ """Resolve the handoff state directory: ``save_dir`` when provided, else
193
+ the config dir (mirrors the report-cache convention in last30days.py).
194
+ Both are accepted as arguments so this module never imports the CLI
195
+ layer above it. Returns None when neither location is available."""
196
+ if save_dir:
197
+ return Path(save_dir).expanduser().resolve()
198
+ if config_dir is not None:
199
+ return Path(config_dir)
200
+ return None
201
+
202
+
203
+ def nominations_bundle_path(state_dir: str | Path) -> Path:
204
+ """The nominations bundle file inside a handoff state directory."""
205
+ return Path(state_dir) / NOMINATIONS_BUNDLE_FILENAME
206
+
207
+
208
+ def pending_report_path(state_dir: str | Path) -> Path:
209
+ """The leg-2 pending-report file inside a handoff state directory."""
210
+ return Path(state_dir) / PENDING_REPORT_FILENAME
211
+
212
+
213
+ def _search_paths(
214
+ save_dir: str | Path | None,
215
+ config_dir: Path | None,
216
+ path_fn: Callable[[Path], Path],
217
+ ) -> list[Path]:
218
+ """Candidate handoff-file locations: ONLY the save dir when one was
219
+ supplied, else the config dir. An explicit save dir is the protocol's
220
+ single handoff store (mirroring ``_scoped_store_db`` and SKILL.md's "a
221
+ different or missing save dir on a later leg means the leg cannot find
222
+ them" contract), so a handoff file in the config dir must never silently
223
+ satisfy a save-dir run. ``path_fn`` picks which handoff file (bundle vs
224
+ pending)."""
225
+ if save_dir:
226
+ return [path_fn(Path(save_dir).expanduser().resolve())]
227
+ if config_dir is not None:
228
+ return [path_fn(Path(config_dir))]
229
+ return []
230
+
231
+
232
+ def _searched_lines(searched: list[Path]) -> str:
233
+ if not searched:
234
+ return " (no --save-dir and no config directory available)"
235
+ return "\n".join(f" - {path}" for path in searched)
236
+
237
+
238
+ def write_nominations_bundle(
239
+ entries: Sequence[PoolEntry],
240
+ *,
241
+ domain: str,
242
+ tier: str,
243
+ from_date: str,
244
+ to_date: str,
245
+ lookback_days: int,
246
+ enrichment_source_boundary: list[str] | None,
247
+ requested_sources: list[str] | None,
248
+ source_status: dict[str, schema.SourceOutcome] | None = None,
249
+ warnings: Sequence[str] | None = None,
250
+ mock: bool = False,
251
+ save_dir: str | Path | None = None,
252
+ config_dir: Path | None = None,
253
+ ) -> NominationsBundle:
254
+ """Write the leg-1 nominations bundle and return its parsed form.
255
+
256
+ Nomination ids are assigned ``n1, n2, ...`` in pool order. The leg-1
257
+ invocation context (enrichment source boundary, requested discovery
258
+ sources, lookback days) rides along so leg 2 resumes with identical
259
+ settings. ``None`` boundaries are preserved as null - "no boundary" and
260
+ "empty boundary" are different contracts. ``source_status`` is the
261
+ sweep's finalized per-source outcome map (serialized via the same
262
+ ``schema.to_dict`` round trip every report uses) so degraded coverage
263
+ survives into legs 2-3; ``warnings`` preserves informational coverage
264
+ receipts, and ``mock`` stamps the writing run's provenance.
265
+ """
266
+ if tier not in _VALID_TIERS:
267
+ raise ValueError(f"tier must be one of {_VALID_TIERS}, got {tier!r}")
268
+ state_dir = handoff_state_dir(save_dir, config_dir)
269
+ if state_dir is None:
270
+ raise HandoffContractError(
271
+ "No handoff location available to write the nominations bundle: "
272
+ "pass --save-dir or configure ~/.config/last30days/."
273
+ )
274
+ bundle_id = secrets.token_hex(8)
275
+ generated_at = schema._utc_now()
276
+
277
+ rows: list[dict[str, Any]] = []
278
+ nominations: list[BundleNomination] = []
279
+ for index, entry in enumerate(entries, start=1):
280
+ nomination_id = f"n{index}"
281
+ sources = sorted({item.source for item in entry.nomination.items})
282
+ engagement = pipeline._discovery_engagement(entry.nomination.items)
283
+ rows.append({
284
+ "id": nomination_id,
285
+ "cluster_id": entry.cluster_id,
286
+ "heuristic_name": entry.heuristic_name,
287
+ "heuristic_junk": bool(entry.heuristic_junk),
288
+ "sources": sources,
289
+ "engagement_by_source": engagement,
290
+ "nomination": schema.nomination_to_dict(entry.nomination),
291
+ })
292
+ nominations.append(BundleNomination(
293
+ nomination_id=nomination_id,
294
+ nomination=entry.nomination,
295
+ cluster_id=entry.cluster_id,
296
+ heuristic_name=entry.heuristic_name,
297
+ heuristic_junk=bool(entry.heuristic_junk),
298
+ sources=sources,
299
+ engagement_by_source=engagement,
300
+ ))
301
+
302
+ payload = {
303
+ "schema_version": schema.DISCOVERY_NOMINATIONS_SCHEMA_VERSION,
304
+ "kind": schema.DISCOVERY_NOMINATIONS_KIND,
305
+ "bundle_id": bundle_id,
306
+ "generated_at": generated_at,
307
+ "from_date": from_date,
308
+ "to_date": to_date,
309
+ "domain": domain,
310
+ "tier": tier,
311
+ "mock": bool(mock),
312
+ "warnings": list(warnings or []),
313
+ "source_status": {
314
+ source: schema.to_dict(outcome)
315
+ for source, outcome in (source_status or {}).items()
316
+ },
317
+ "context": {
318
+ "enrichment_source_boundary": (
319
+ list(enrichment_source_boundary)
320
+ if enrichment_source_boundary is not None
321
+ else None
322
+ ),
323
+ "requested_sources": (
324
+ list(requested_sources) if requested_sources is not None else None
325
+ ),
326
+ "lookback_days": int(lookback_days),
327
+ },
328
+ "nominations": rows,
329
+ }
330
+ path = nominations_bundle_path(state_dir)
331
+ try:
332
+ state_dir.mkdir(parents=True, exist_ok=True)
333
+ path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
334
+ except OSError as exc:
335
+ # A locked/read-only/full disk is the protocol's clean exit-2 path,
336
+ # never a traceback.
337
+ raise HandoffContractError(
338
+ f"Could not write nominations bundle {path}: {exc}"
339
+ ) from exc
340
+ return NominationsBundle(
341
+ schema_version=schema.DISCOVERY_NOMINATIONS_SCHEMA_VERSION,
342
+ bundle_id=bundle_id,
343
+ generated_at=generated_at,
344
+ from_date=from_date,
345
+ to_date=to_date,
346
+ domain=domain,
347
+ tier=tier,
348
+ enrichment_source_boundary=(
349
+ list(enrichment_source_boundary)
350
+ if enrichment_source_boundary is not None
351
+ else None
352
+ ),
353
+ requested_sources=(
354
+ list(requested_sources) if requested_sources is not None else None
355
+ ),
356
+ lookback_days=int(lookback_days),
357
+ nominations=nominations,
358
+ source_status=dict(source_status or {}),
359
+ mock=bool(mock),
360
+ path=path,
361
+ warnings=list(warnings or []),
362
+ )
363
+
364
+
365
+ def read_nominations_bundle(
366
+ *,
367
+ save_dir: str | Path | None = None,
368
+ config_dir: Path | None = None,
369
+ ) -> NominationsBundle:
370
+ """Locate and parse the nominations bundle for legs 2 and 3.
371
+
372
+ The bundle lives in the save dir when one was supplied, else the config
373
+ dir - never both (no cross-store fallback). Raises HandoffContractError
374
+ (naming the searched location and the re-sweep remedy) when no bundle
375
+ exists, and for any top-level contract violation in the file found.
376
+ """
377
+ searched = _search_paths(save_dir, config_dir, nominations_bundle_path)
378
+ path = next((candidate for candidate in searched if candidate.exists()), None)
379
+ if path is None:
380
+ raise HandoffContractError(
381
+ "No discovery nominations bundle found. Searched:\n"
382
+ f"{_searched_lines(searched)}\n{_RESWEEP_REMEDY}"
383
+ )
384
+ return _parse_bundle_file(path)
385
+
386
+
387
+ def _parse_handoff_envelope(
388
+ path: Path,
389
+ *,
390
+ label: str,
391
+ kind: str,
392
+ schema_version: str,
393
+ remedy: str,
394
+ missing_id_context: str,
395
+ stale_context: str,
396
+ ) -> tuple[dict[str, Any], str, Any]:
397
+ """Shared strict top-level validation for the two engine-written handoff
398
+ files (nominations bundle, pending report): readable, valid JSON object,
399
+ right kind and schema version, bundle_id present, within TTL. Returns
400
+ (payload, bundle_id, generated_at)."""
401
+ try:
402
+ raw = path.read_text(encoding="utf-8")
403
+ except OSError as exc:
404
+ raise HandoffContractError(
405
+ f"Could not read {label.lower()} {path}: {exc}"
406
+ ) from exc
407
+ try:
408
+ payload = json.loads(raw)
409
+ except json.JSONDecodeError as exc:
410
+ raise HandoffContractError(
411
+ f"{label} {path} is not valid JSON: {exc}"
412
+ ) from exc
413
+ if not isinstance(payload, dict):
414
+ raise HandoffContractError(
415
+ f"{label} {path} must be a top-level JSON object, "
416
+ f"got {type(payload).__name__}."
417
+ )
418
+ version = payload.get("schema_version")
419
+ if version != schema_version:
420
+ raise HandoffContractError(
421
+ f"{label} {path} has schema version {version!r}; this "
422
+ f"build reads {schema_version!r}. {remedy}"
423
+ )
424
+ file_kind = payload.get("kind")
425
+ if file_kind != kind:
426
+ raise HandoffContractError(
427
+ f"{label} {path} has kind {file_kind!r}; expected "
428
+ f"{kind!r}. {remedy}"
429
+ )
430
+ bundle_id = str(payload.get("bundle_id") or "")
431
+ if not bundle_id:
432
+ raise HandoffContractError(
433
+ f"{label} {path} is missing its bundle_id; "
434
+ f"{missing_id_context}. {remedy}"
435
+ )
436
+ generated_at = payload.get("generated_at")
437
+ if not env.is_timestamp_fresh(generated_at, DISCOVERY_HANDOFF_TTL_SECONDS):
438
+ raise HandoffContractError(
439
+ f"{label} {path} is stale (generated_at="
440
+ f"{generated_at!r}, TTL {int(DISCOVERY_HANDOFF_TTL_SECONDS)}s): "
441
+ f"{stale_context}. {remedy}"
442
+ )
443
+ return payload, bundle_id, generated_at
444
+
445
+
446
+ def _parse_bundle_file(path: Path) -> NominationsBundle:
447
+ payload, bundle_id, generated_at = _parse_handoff_envelope(
448
+ path,
449
+ label="Nominations bundle",
450
+ kind=schema.DISCOVERY_NOMINATIONS_KIND,
451
+ schema_version=schema.DISCOVERY_NOMINATIONS_SCHEMA_VERSION,
452
+ remedy=_RESWEEP_REMEDY,
453
+ missing_id_context="judgments cannot bind to it",
454
+ stale_context="the momentum window it captured has moved on",
455
+ )
456
+ version = payload.get("schema_version")
457
+
458
+ context = payload.get("context") or {}
459
+ boundary = context.get("enrichment_source_boundary")
460
+ requested = context.get("requested_sources")
461
+ try:
462
+ lookback_days = int(context.get("lookback_days") or 30)
463
+ except (TypeError, ValueError):
464
+ lookback_days = 30
465
+
466
+ rows_raw = payload.get("nominations")
467
+ if not isinstance(rows_raw, list):
468
+ raise HandoffContractError(
469
+ f"Nominations bundle {path} must carry a top-level "
470
+ f"\"nominations\" list, got {type(rows_raw).__name__}. "
471
+ f"{_RESWEEP_REMEDY}"
472
+ )
473
+
474
+ nominations: list[BundleNomination] = []
475
+ for position, row in enumerate(rows_raw, start=1):
476
+ # Lenient per row: the bundle is engine-written, but one corrupted
477
+ # row must not discard the rest of the pool.
478
+ if not isinstance(row, dict):
479
+ _warn(
480
+ f"skipping malformed nomination row {position} in "
481
+ f"{path.name} (not an object)"
482
+ )
483
+ continue
484
+ try:
485
+ nomination = pipeline.Nomination(
486
+ **schema.nomination_kwargs_from_dict(row.get("nomination") or {})
487
+ )
488
+ except (KeyError, TypeError, ValueError) as exc:
489
+ _warn(
490
+ f"skipping unparseable nomination row {position} in "
491
+ f"{path.name}: {type(exc).__name__}: {exc}"
492
+ )
493
+ continue
494
+ engagement_raw = row.get("engagement_by_source")
495
+ engagement = {
496
+ str(source): dict(metrics)
497
+ for source, metrics in (
498
+ engagement_raw.items() if isinstance(engagement_raw, dict) else ()
499
+ )
500
+ if isinstance(metrics, dict)
501
+ }
502
+ nominations.append(BundleNomination(
503
+ nomination_id=str(row.get("id") or f"n{position}"),
504
+ nomination=nomination,
505
+ cluster_id=str(row.get("cluster_id") or ""),
506
+ heuristic_name=str(row.get("heuristic_name") or ""),
507
+ heuristic_junk=bool(row.get("heuristic_junk")),
508
+ sources=[str(source) for source in row.get("sources") or []],
509
+ engagement_by_source=engagement,
510
+ ))
511
+
512
+ if not nominations:
513
+ # Leg 1 never writes an empty bundle (a zero-nomination sweep
514
+ # short-circuits with no bundle file), so an empty or all-invalid
515
+ # nominations array is corrupt state: fail closed, never hand the
516
+ # resume leg a silently empty pool.
517
+ raise HandoffContractError(
518
+ f"Nominations bundle {path} contains no readable nominations "
519
+ f"(leg 1 never writes an empty pool). {_RESWEEP_REMEDY}"
520
+ )
521
+
522
+ # Sweep status is advisory coverage context: restore it through the same
523
+ # deserializer every report uses, but degrade a malformed map to empty
524
+ # rather than discarding an otherwise-valid pool.
525
+ try:
526
+ source_status = schema._source_status_from_dict(payload)
527
+ except (AttributeError, KeyError, TypeError, ValueError):
528
+ _warn(f"ignoring malformed source_status map in {path.name}")
529
+ source_status = {}
530
+
531
+ warnings = payload.get("warnings", [])
532
+ if not isinstance(warnings, list) or any(not isinstance(warning, str) for warning in warnings):
533
+ _warn(f"ignoring malformed warnings list in {path.name}")
534
+ warnings = []
535
+
536
+ return NominationsBundle(
537
+ schema_version=str(version),
538
+ bundle_id=bundle_id,
539
+ generated_at=str(generated_at or ""),
540
+ from_date=str(payload.get("from_date") or ""),
541
+ to_date=str(payload.get("to_date") or ""),
542
+ domain=str(payload.get("domain") or ""),
543
+ tier=str(payload.get("tier") or "deep"),
544
+ enrichment_source_boundary=(
545
+ [str(source) for source in boundary]
546
+ if isinstance(boundary, list) else None
547
+ ),
548
+ requested_sources=(
549
+ [str(source) for source in requested]
550
+ if isinstance(requested, list) else None
551
+ ),
552
+ lookback_days=lookback_days,
553
+ nominations=nominations,
554
+ source_status=source_status,
555
+ mock=bool(payload.get("mock")),
556
+ path=path,
557
+ warnings=warnings,
558
+ )
559
+
560
+
561
+ def read_pending_report(
562
+ *,
563
+ save_dir: str | Path | None = None,
564
+ config_dir: Path | None = None,
565
+ ) -> PendingReport:
566
+ """Locate and parse the leg-2 pending report for the finalize leg.
567
+
568
+ Same strictness family as the bundle reader: missing file (the searched
569
+ location named - save dir when supplied, else config dir, never a
570
+ cross-store fallback), unreadable, invalid JSON, wrong kind or schema version,
571
+ missing bundle_id, or stale TTL all raise HandoffContractError (mapped to
572
+ exit 2 by the CLI layer). Staleness is measured from the PENDING report's
573
+ own generated_at - the leg-2 write started a fresh authoring window - and
574
+ the remedy is the resume leg, not a full re-sweep.
575
+ """
576
+ searched = _search_paths(save_dir, config_dir, pending_report_path)
577
+ path = next((candidate for candidate in searched if candidate.exists()), None)
578
+ if path is None:
579
+ raise HandoffContractError(
580
+ "No pending discovery report found. Searched:\n"
581
+ f"{_searched_lines(searched)}\n{_RESUME_REMEDY}"
582
+ )
583
+ return _parse_pending_file(path)
584
+
585
+
586
+ def _parse_pending_file(path: Path) -> PendingReport:
587
+ payload, bundle_id, generated_at = _parse_handoff_envelope(
588
+ path,
589
+ label="Pending discovery report",
590
+ kind=schema.DISCOVERY_PENDING_KIND,
591
+ schema_version=schema.DISCOVERY_PENDING_SCHEMA_VERSION,
592
+ remedy=_RESUME_REMEDY,
593
+ missing_id_context="angles cannot bind to it",
594
+ stale_context="the judged window it captured has moved on",
595
+ )
596
+ version = payload.get("schema_version")
597
+ report = payload.get("report")
598
+ if not isinstance(report, dict):
599
+ raise HandoffContractError(
600
+ f"Pending discovery report {path} must carry a top-level "
601
+ f"\"report\" object. {_RESUME_REMEDY}"
602
+ )
603
+ # Lenient per row (engine-written, but one corrupt row must not discard
604
+ # the rest): keep only well-shaped angle-input entries.
605
+ angle_inputs_raw = payload.get("angle_inputs")
606
+ angle_inputs = {
607
+ str(nomination_id): {
608
+ str(key): str(value) for key, value in info.items()
609
+ }
610
+ for nomination_id, info in (
611
+ angle_inputs_raw.items() if isinstance(angle_inputs_raw, dict) else ()
612
+ )
613
+ if isinstance(info, dict)
614
+ }
615
+ return PendingReport(
616
+ schema_version=str(version),
617
+ bundle_id=bundle_id,
618
+ generated_at=str(generated_at or ""),
619
+ run_ref=str(payload.get("run_ref") or ""),
620
+ report=report,
621
+ angle_inputs=angle_inputs,
622
+ mock=bool(payload.get("mock")),
623
+ path=path,
624
+ )
625
+
626
+
627
+ def _load_host_file(path: str | Path, label: str) -> dict[str, Any]:
628
+ """Load a host-authored handoff file with strict top-level checks."""
629
+ file_path = Path(path).expanduser()
630
+ try:
631
+ raw = file_path.read_text(encoding="utf-8")
632
+ except OSError as exc:
633
+ raise HandoffContractError(
634
+ f"Could not read {label} file {file_path}: {exc}"
635
+ ) from exc
636
+ try:
637
+ payload = json.loads(raw)
638
+ except json.JSONDecodeError as exc:
639
+ raise HandoffContractError(
640
+ f"{label.capitalize()} file {file_path} is not valid JSON: {exc}"
641
+ ) from exc
642
+ if not isinstance(payload, dict):
643
+ raise HandoffContractError(
644
+ f"{label.capitalize()} file {file_path} must be a top-level JSON "
645
+ f"object, got {type(payload).__name__}."
646
+ )
647
+ return payload
648
+
649
+
650
+ def _require_bundle_binding(
651
+ payload: dict[str, Any],
652
+ bundle: NominationsBundle | PendingReport,
653
+ *,
654
+ label: str,
655
+ save_dir: str | Path | None,
656
+ config_dir: Path | None,
657
+ ) -> None:
658
+ """Enforce bundle-id binding between a host file and the current bundle
659
+ (or, on the finalize leg, the pending report that inherited its id).
660
+ The mismatch message names the file actually validated against - the
661
+ pending report on the finalize leg - so a host's retry is not misdirected
662
+ at the nominations bundle. A mismatch means the host echoed the wrong id
663
+ into an otherwise-current file, so the remedy is the cheap one - correct
664
+ the bundle_id field and re-run this same leg - never the expensive
665
+ re-sweep/resume remedies (those belong to missing/stale state)."""
666
+ file_bundle_id = str(payload.get("bundle_id") or "")
667
+ if file_bundle_id == bundle.bundle_id:
668
+ return
669
+ if isinstance(bundle, PendingReport):
670
+ searched = _search_paths(save_dir, config_dir, pending_report_path)
671
+ noun = "current pending discovery report"
672
+ location_label = "Pending-report locations searched"
673
+ else:
674
+ searched = _search_paths(save_dir, config_dir, nominations_bundle_path)
675
+ noun = "current nominations bundle"
676
+ location_label = "Bundle locations searched"
677
+ if not searched and bundle.path is not None:
678
+ searched = [bundle.path]
679
+ raise HandoffContractError(
680
+ f"The {label} file is bound to bundle_id {file_bundle_id!r} but the "
681
+ f"{noun} is {bundle.bundle_id!r}. {location_label}:\n"
682
+ f"{_searched_lines(searched)}\n"
683
+ f"Correct the bundle_id field in your {label} file to "
684
+ f"{bundle.bundle_id!r} and re-run this same leg."
685
+ )
686
+
687
+
688
+ def _truncate_at_word(text: str, max_chars: int) -> str:
689
+ """Cap ``text`` at ``max_chars``, cutting back to a word boundary and
690
+ stripping trailing punctuation. Text within the cap passes through
691
+ untouched."""
692
+ if len(text) <= max_chars:
693
+ return text
694
+ return text[:max_chars].rsplit(" ", 1)[0].rstrip(_TRUNCATE_STRIP_CHARS)
695
+
696
+
697
+ def _sanitized_name(raw: object) -> str | None:
698
+ """One whitespace-collapsed, punctuation-stripped, length-capped topic
699
+ name, or None for anything unusable (non-strings, and names that
700
+ sanitize to empty - e.g. emoji-only - count as per-row-absent)."""
701
+ if not isinstance(raw, str):
702
+ return None
703
+ name = " ".join(raw.split()).strip(_TRUNCATE_STRIP_CHARS)
704
+ name = _truncate_at_word(name, _NAME_MAX_CHARS)
705
+ if not any(char.isalnum() for char in name):
706
+ return None
707
+ return name
708
+
709
+
710
+ def _sanitized_angle(raw: object) -> str | None:
711
+ """One whitespace-collapsed, length-capped angle sentence, or None for
712
+ anything unusable. Non-strings are rejected outright, never coerced."""
713
+ if not isinstance(raw, str):
714
+ return None
715
+ text = _truncate_at_word(" ".join(raw.split()), _ANGLE_MAX_CHARS)
716
+ return text or None
717
+
718
+
719
+ def _known_rows(
720
+ rows: list[Any],
721
+ known: set[str],
722
+ *,
723
+ row_label: str,
724
+ unknown_label: str,
725
+ ) -> Iterator[tuple[str, dict[str, Any]]]:
726
+ """Shared lenient per-row gate for host-authored files: skip non-object
727
+ rows, rows with no nomination id, and rows for unknown ids - warning on
728
+ each - and yield (row_id, row) for the rest."""
729
+ for row in rows:
730
+ if not isinstance(row, dict):
731
+ _warn(f"skipping malformed {row_label} row (not an object)")
732
+ continue
733
+ row_id = str(row.get("id") or "").strip()
734
+ if not row_id:
735
+ _warn(f"skipping {row_label} row with no nomination id")
736
+ continue
737
+ if row_id not in known:
738
+ _warn(f"ignoring {unknown_label} for unknown nomination id {row_id!r}")
739
+ continue
740
+ yield row_id, row
741
+
742
+
743
+ def _clamped_worthiness(raw: object) -> int | None:
744
+ """Worthiness clamped to 0-100 integers; anything non-numeric is absent."""
745
+ if isinstance(raw, bool):
746
+ return None
747
+ try:
748
+ value = float(raw) # type: ignore[arg-type]
749
+ except (TypeError, ValueError):
750
+ return None
751
+ return max(0, min(100, round(value)))
752
+
753
+
754
+ def read_judgments(
755
+ path: str | Path,
756
+ bundle: NominationsBundle,
757
+ *,
758
+ save_dir: str | Path | None = None,
759
+ config_dir: Path | None = None,
760
+ ) -> dict[str, HostJudgment]:
761
+ """Read the host judgments file for leg 2, keyed by nomination id.
762
+
763
+ Strict at the top level (readable, valid JSON object, ``judgments`` list,
764
+ bundle_id bound to ``bundle``), lenient per row: an unknown id is warned
765
+ and ignored, a missing/unusable name or junk field is per-row-absent, and
766
+ worthiness is clamped to 0-100 integers. Nominations with no row at all
767
+ are simply missing from the mapping - use ``judgment_for`` to get the
768
+ ROW_ABSENT marker for them.
769
+ """
770
+ payload = _load_host_file(path, "judgments")
771
+ _require_bundle_binding(
772
+ payload, bundle, label="judgments", save_dir=save_dir, config_dir=config_dir,
773
+ )
774
+ rows = payload.get("judgments")
775
+ if not isinstance(rows, list):
776
+ raise HandoffContractError(
777
+ f"Judgments file {path} must carry a top-level \"judgments\" list."
778
+ )
779
+ known = {entry.nomination_id for entry in bundle.nominations}
780
+ judgments: dict[str, HostJudgment] = {}
781
+ for row_id, row in _known_rows(
782
+ rows, known, row_label="judgments", unknown_label="judgment"
783
+ ):
784
+ # Only a real JSON boolean is a junk verdict: null, "false", 0, or
785
+ # any other non-bool value is per-row-absent (bundle heuristic),
786
+ # never coerced - bool("false") is True.
787
+ raw_junk = row.get("junk")
788
+ judgments[row_id] = HostJudgment(
789
+ name=_sanitized_name(row.get("name")),
790
+ junk=raw_junk if isinstance(raw_junk, bool) else None,
791
+ worthiness=_clamped_worthiness(row.get("worthiness")),
792
+ )
793
+ return judgments
794
+
795
+
796
+ def judgment_for(
797
+ judgments: dict[str, HostJudgment],
798
+ nomination_id: str,
799
+ ) -> HostJudgment:
800
+ """The host's verdict for one nomination, or ROW_ABSENT when the host
801
+ omitted the row (caller falls back to the bundle's heuristic name/junk)."""
802
+ return judgments.get(nomination_id, ROW_ABSENT)
803
+
804
+
805
+ def read_angles(
806
+ path: str | Path | None,
807
+ bundle: NominationsBundle | PendingReport,
808
+ *,
809
+ save_dir: str | Path | None = None,
810
+ config_dir: Path | None = None,
811
+ ) -> dict[str, HostAngles]:
812
+ """Read the host angles file for leg 3, keyed by nomination id.
813
+
814
+ ``bundle`` is the binding target: the finalize leg passes the pending
815
+ report (the bundle_id echo validates against it, and the known ids are
816
+ its surviving ``angle_inputs`` ids), while a NominationsBundle binds
817
+ against the full pool. A missing angles file is legal: ``path=None``
818
+ returns an empty mapping and every topic ships without angles. When a
819
+ path is given the same strict-top-level / lenient-per-row rules as
820
+ judgments apply; angle sentences are word-boundary capped at 200 chars.
821
+ """
822
+ if path is None:
823
+ return {}
824
+ payload = _load_host_file(path, "angles")
825
+ _require_bundle_binding(
826
+ payload, bundle, label="angles", save_dir=save_dir, config_dir=config_dir,
827
+ )
828
+ rows = payload.get("angles")
829
+ if not isinstance(rows, list):
830
+ raise HandoffContractError(
831
+ f"Angles file {path} must carry a top-level \"angles\" list."
832
+ )
833
+ known = (
834
+ set(bundle.angle_inputs)
835
+ if isinstance(bundle, PendingReport)
836
+ else {entry.nomination_id for entry in bundle.nominations}
837
+ )
838
+ angles: dict[str, HostAngles] = {}
839
+ for row_id, row in _known_rows(
840
+ rows, known, row_label="angles", unknown_label="angles"
841
+ ):
842
+ podcast = _sanitized_angle(row.get("podcast"))
843
+ x_article = _sanitized_angle(row.get("x_article"))
844
+ if podcast is None and x_article is None:
845
+ # No usable hook at all: treat the row as absent.
846
+ continue
847
+ angles[row_id] = HostAngles(podcast=podcast, x_article=x_article)
848
+ return angles
849
+
850
+
851
+ def resolve_name_collisions(
852
+ pairs: Sequence[tuple[pipeline.Nomination, str]],
853
+ ) -> list[str]:
854
+ """Re-run the nominate-stage casefold/entity-token collision rules over
855
+ host-applied names, returning one collision-free name per input pair in
856
+ order.
857
+
858
+ Short host-judged names collide far more often than raw titles; a
859
+ colliding name gets the later nomination's strongest non-shared entity
860
+ token appended (``pipeline._disambiguated_topic_name``, fed synthetic
861
+ per-nomination clusters built from the seed items). Unlike the nominate
862
+ stage, a collision can never DROP a nomination here - the pool already
863
+ de-duplicated same-story clusters at leg 1 - so when no distinguishing
864
+ entity token exists the name falls back to an ordinal suffix.
865
+ """
866
+ candidate_map: dict[str, schema.Candidate] = {}
867
+ clusters: list[schema.Cluster] = []
868
+ for index, (nomination, _applied) in enumerate(pairs):
869
+ candidate_ids: list[str] = []
870
+ for item_index, item in enumerate(nomination.items):
871
+ candidate_id = f"handoff-{index}-{item_index}"
872
+ candidate_map[candidate_id] = schema.Candidate(
873
+ candidate_id=candidate_id,
874
+ item_id=item.item_id,
875
+ source=item.source,
876
+ title=item.title,
877
+ url=item.url,
878
+ snippet=item.snippet,
879
+ subquery_labels=[],
880
+ native_ranks={},
881
+ local_relevance=0.0,
882
+ freshness=0,
883
+ engagement=None,
884
+ source_quality=0.0,
885
+ rrf_score=0.0,
886
+ )
887
+ candidate_ids.append(candidate_id)
888
+ clusters.append(schema.Cluster(
889
+ cluster_id=f"handoff-n{index}",
890
+ title=nomination.name,
891
+ candidate_ids=candidate_ids,
892
+ representative_ids=candidate_ids[:1],
893
+ sources=sorted({item.source for item in nomination.items}),
894
+ score=nomination.seed_score,
895
+ ))
896
+
897
+ resolved_names: list[str] = []
898
+ taken: dict[str, schema.Cluster] = {}
899
+ entity_counts_cache: dict[str, Counter] = {}
900
+ for index, (_nomination, applied) in enumerate(pairs):
901
+ cluster = clusters[index]
902
+ name = applied
903
+ key = name.casefold()
904
+ if key in taken:
905
+ resolved = pipeline._disambiguated_topic_name(
906
+ name, cluster, taken[key], candidate_map, entity_counts_cache,
907
+ taken,
908
+ )
909
+ if resolved is None:
910
+ # Indistinguishable by content: keep the nomination anyway
911
+ # (distinct stories at leg 1) under an ordinal suffix.
912
+ suffix = 2
913
+ while f"{name} {suffix}".casefold() in taken:
914
+ suffix += 1
915
+ resolved = f"{name} {suffix}"
916
+ name = resolved
917
+ key = name.casefold()
918
+ taken[key] = cluster
919
+ resolved_names.append(name)
920
+ return resolved_names
921
+
922
+
923
+ def _one_line(text: str) -> str:
924
+ return " ".join(text.split())
925
+
926
+
927
+ def build_host_digest(bundle: NominationsBundle) -> str:
928
+ """The host-facing judging digest for a nominations bundle: plain,
929
+ promptable text with one structural line per nomination (id, seed source
930
+ names, velocity/engagement signal) plus capped evidence lines (leader
931
+ title, leader snippet, strongest community comment - the surface the
932
+ engine judge used to see). Names the bundle file and instructs the host
933
+ to read its full evidence before judging.
934
+
935
+ The evidence lines are scraped third-party text, so they are fenced the
936
+ way the deleted engine judge fenced its candidate block (the exact
937
+ ``rerank._fenced_untrusted_content`` fence: a security-notice header
938
+ stating the fenced content is data, never instructions, around
939
+ ``<untrusted_content>`` tags). The structural lines - nomination ids,
940
+ sources, signal, bundle path, judging instructions - stay outside the
941
+ fence."""
942
+ location = str(bundle.path) if bundle.path is not None else (
943
+ NOMINATIONS_BUNDLE_FILENAME
944
+ )
945
+ domain_label = bundle.domain or "global trending (no domain filter)"
946
+ lines = [
947
+ f"Discovery nominations awaiting host judgment "
948
+ f"({len(bundle.nominations)} topics).",
949
+ f"Domain: {domain_label} | window {bundle.from_date} -> "
950
+ f"{bundle.to_date} | tier {bundle.tier}",
951
+ f"Bundle file: {location} (bundle_id {bundle.bundle_id})",
952
+ "Read the bundle file's per-nomination evidence before judging; the "
953
+ "lines below are only a digest.",
954
+ "",
955
+ ]
956
+ evidence_lines: list[str] = []
957
+ for entry in bundle.nominations:
958
+ items = entry.nomination.items
959
+ leader = items[0] if items else None
960
+ title = _one_line((leader.title if leader else "") or entry.nomination.name)
961
+ sources = ", ".join(entry.sources) if entry.sources else "unknown"
962
+ native_total = sum(
963
+ rerank.discovery_engagement_total(item) for item in items
964
+ )
965
+ lines.append(
966
+ f"{entry.nomination_id} | sources: {sources} | "
967
+ f"signal: seed velocity {entry.nomination.seed_score:.1f}, "
968
+ f"{native_total:,.0f} native interactions"
969
+ )
970
+ evidence_lines.append(f"- id: {entry.nomination_id}")
971
+ evidence_lines.append(f" title: {title[:_DIGEST_TITLE_MAX_CHARS]}")
972
+ snippet_text = _one_line(
973
+ (leader.snippet if leader else "") or entry.nomination.summary
974
+ )
975
+ if snippet_text:
976
+ evidence_lines.append(
977
+ f" snippet: {snippet_text[:_DIGEST_SNIPPET_MAX_CHARS]}"
978
+ )
979
+ top_comment = pipeline._best_community_comment(items)
980
+ if top_comment:
981
+ evidence_lines.append(
982
+ f" top comment: "
983
+ f"{_one_line(top_comment)[:_DIGEST_COMMENT_MAX_CHARS]}"
984
+ )
985
+ if evidence_lines:
986
+ lines.append("")
987
+ lines.append(rerank._fenced_untrusted_content("\n".join(evidence_lines)))
988
+ return "\n".join(lines)