From 949bcf8942eaf2e59ea082ce7afac9f1b32642c2 Mon Sep 17 00:00:00 2001 From: Matt Van Horn Date: Wed, 22 Apr 2026 21:31:00 -0700 Subject: [PATCH] feat: vs mode N full passes + --competitors auto-discovery + (/Last30Days) title (#312) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: vs mode runs N full passes; --competitors wraps vs with auto-discovery Unifies vs-mode and --competitors onto one fanout architecture. A topic containing "vs" / "versus" now runs N full pipeline.run() calls in parallel (reverting the one-pass latency optimization that removed per-entity depth); --competitors becomes a SKILL.md-level shortcut where the hosting reasoning model (Claude Code, Codex, Hermes, Gemini) discovers N peers via its own WebSearch, runs Step 0.55 per entity, and invokes the engine with a vs-topic + --competitors-plan JSON. Changed: - vs-mode: N full passes in parallel via fanout (was 1 merged pass). - --competitors: SKILL.md shortcut for vs-mode-with-discovery. Engine flag kept for headless/cron use. LAW 7-style stderr reframed to lead with the hosting-model path (use WebSearch + --competitors-plan) instead of BRAVE_API_KEY. Footer BRAVE/SERPER nudge suppressed when --plan or --competitors-plan present (hosting model already has WebSearch). Added: - --competitors-plan JSON flag: per-entity {x_handle, x_related, subreddits, github_user, github_repos, context}. Accepts inline JSON or file path. subrun_kwargs_for helper is the single source of truth for per-entity kwargs — no closure-default fallthrough from main scope. - Per-entity save files: each entity's sub-run produces its own {slug}-raw.md with a single-row Resolved Entities block. - --polymarket-keywords filter for ambiguous single-token topics. Fixed: - test_competitor_subrun_isolation regression suite locks in 3.0.12's no-leak invariant (main flags do not inherit into peer sub-runs). - Updates test_regression.py for the new comparison-mode payload shape. Bumps plugin.json to 3.0.13. 1,219 tests passing. Co-Authored-By: Claude Opus 4.7 (1M context) * fix: comparison title attribution — (Last 30 Days) → (/Last30Days) User feedback on 3.0.13 dogfood runs (Kanye vs Drake, Mercer Island, Figma): the comparison-mode synthesis title should attribute to the slash command rather than restate the date range. Three SKILL.md occurrences updated. Pure documentation change. Bumps to 3.0.14. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 (1M context) --- .claude-plugin/plugin.json | 2 +- CHANGELOG.md | 25 + README.md | 2 +- SKILL.md | 64 ++- ...-model-resolve-and-leak-plan.md.superseded | 394 +++++++++++++++ ...-passes-competitors-auto-discovery-plan.md | 451 ++++++++++++++++++ ...comparison-title-skill-attribution-plan.md | 87 ++++ scripts/last30days.py | 277 +++++++++-- scripts/lib/pipeline.py | 8 +- scripts/lib/polymarket.py | 33 ++ scripts/lib/render.py | 11 + tests/test_competitor_subrun_isolation.py | 196 ++++++++ tests/test_competitors_plan_threading.py | 180 +++++++ tests/test_footer_nudge_suppression.py | 76 +++ tests/test_polymarket_disambiguation.py | 74 +++ tests/test_regression.py | 66 ++- tests/test_save_raw_per_entity.py | 84 ++++ tests/test_vs_mode_fanout.py | 63 +++ 18 files changed, 2022 insertions(+), 71 deletions(-) create mode 100644 docs/plans/2026-04-22-004-fix-competitors-hosting-model-resolve-and-leak-plan.md.superseded create mode 100644 docs/plans/2026-04-22-005-feat-vs-mode-n-passes-competitors-auto-discovery-plan.md create mode 100644 docs/plans/2026-04-22-006-fix-comparison-title-skill-attribution-plan.md create mode 100644 tests/test_competitor_subrun_isolation.py create mode 100644 tests/test_competitors_plan_threading.py create mode 100644 tests/test_footer_nudge_suppression.py create mode 100644 tests/test_polymarket_disambiguation.py create mode 100644 tests/test_save_raw_per_entity.py create mode 100644 tests/test_vs_mode_fanout.py diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 54f386f..2be7c60 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "last30days", - "version": "3.0.12", + "version": "3.0.14", "description": "Research any topic across Reddit, X, YouTube, TikTok, Instagram, Hacker News, Polymarket, GitHub, and 5+ more sources. AI agent scores by upvotes, likes, and real money - not editors.", "author": { "name": "Matt Van Horn", diff --git a/CHANGELOG.md b/CHANGELOG.md index ecc3f87..9d42cb2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,31 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [3.0.14] - 2026-04-22 + +### Changed + +- **Comparison-mode title attribution.** The synthesis title for vs-mode and `--competitors` outputs changes from `What the Community Says (Last 30 Days)` to `What the Community Says (/Last30Days)`. Surfaces the slash-command identity instead of restating the date range. Three SKILL.md occurrences updated; pure documentation change. + +## [3.0.13] - 2026-04-22 + +### Changed + +- **vs mode runs N full passes in parallel, one per entity.** Architectural revert of the 3-pass → 1-pass latency optimization from an earlier version. `/last30days "OpenAI vs Anthropic vs xAI"` now runs three full `pipeline.run()` calls in parallel via the same fanout `--competitors` uses, producing three `*-raw.md` save files plus a merged comparison output. Each entity gets its own Step 0.55-grade targeting, own primary X handle weight, own subreddit scoping — apples-to-apples depth instead of the one-pool merged retrieval the single-pass path produced. Parallel execution keeps wall clock ≈ single pass. +- **`--competitors` is now a SKILL.md-level shortcut for vs-mode with auto-discovery.** The hosting reasoning model (Claude Code, Codex, Hermes, Gemini, any agent with WebSearch) performs discovery and Step 0.55 per entity via its own WebSearch tool, then invokes the engine with a vs-topic and `--competitors-plan` JSON. The engine flag remains for headless/cron use with BRAVE/EXA/SERPER/PARALLEL/OPENROUTER keys (engine-internal `auto_resolve` stays as fallback). +- **LAW 7-style stderr for `--competitors` with no backend** now leads with the hosting-model path (WebSearch + Step 0.55 + `--competitors-plan`) instead of `BRAVE_API_KEY`. API-key framing moved to a secondary "headless" section. + +### Added + +- **`--competitors-plan` JSON flag** for per-entity Step 0.55 targeting. Schema: `{entity_name: {x_handle?, x_related?, subreddits?, github_user?, github_repos?, context?}}`. Accepts inline JSON or a file path (matches `--plan`). When present for an entity, skips engine-internal `auto_resolve` and uses the provided values; missing fields fall back to `auto_resolve` (if backend) or planner defaults. Case-insensitive entity matching. The `subrun_kwargs_for` helper is the single source of truth for per-entity kwargs — no closure-default fallthrough from main scope. +- **Per-entity save files** when `--save-dir` is set on a vs-mode or `--competitors` run. Each entity's sub-run produces its own `{slug}-raw.md` with a single-row Resolved Entities block — matches historical vs-mode behavior (N passes → N save files). +- **`--polymarket-keywords "kw1,kw2"`** to filter Polymarket matches for ambiguous single-token topics (e.g., "Warriors" → `nba,gsw,golden-state` kills Glasgow Warriors rugby and Honor of Kings Rogue Warriors noise). + +### Fixed + +- **BRAVE/SERPER footer nudge suppressed** when `--plan` or `--competitors-plan` is present. The nudge told Claude Code users to set an API key when they already have WebSearch via the hosting model. Nudge still fires for true headless runs (no `--plan`, no backend) where the advice is correct. +- **Override-leak regression testing.** 3.0.12 already fixed the main-topic `--subreddits` / `--x-handle` / `--github-*` from leaking into peer sub-runs via explicit per-entity kwargs scrubbing. This release adds a 4-test regression suite (`test_competitor_subrun_isolation.py`) locking in the invariant. + ## [3.0.12] - 2026-04-22 ### Fixed diff --git a/README.md b/README.md index 5041867..518d59c 100644 --- a/README.md +++ b/README.md @@ -116,7 +116,7 @@ When the same story appears on Reddit, X, and YouTube, v3 merges them into one c ### Auto-discovered competitor comparisons -`/last30days OpenAI --competitors` discovers the top 2 peers via web search (Anthropic, xAI), runs the full pipeline on each in parallel, and returns one 3-way comparison report. Override with `--competitors=N` (range 1..6) or `--competitors-list="A,B,C"`. +`/last30days OpenAI --competitors` tells the hosting reasoning model to discover the top 2 peers via WebSearch (Anthropic, xAI), run Step 0.55 per entity, and invoke the engine with `"OpenAI vs Anthropic vs xAI"` and a per-entity `--competitors-plan` JSON. The engine fans out 3 full pipelines in parallel, saves a `*-raw.md` file per entity, and merges them into a 3-way comparison. Same mechanics power `/last30days "OpenAI vs Anthropic vs xAI"` directly. ### GitHub person-mode diff --git a/SKILL.md b/SKILL.md index 78a6e6d..b645e4e 100644 --- a/SKILL.md +++ b/SKILL.md @@ -110,7 +110,7 @@ Replace `{VERSION}` with the installed plugin version (`jq -r '.version' "$SKILL **Placement by query type:** - GENERAL / NEWS / PROMPTING / RECOMMENDATIONS: badge on line 1, blank line 2, `What I learned:` on line 3, then bold-lead-in paragraphs -- COMPARISON: badge on line 1, blank line 2, `# {TOPIC_A} vs {TOPIC_B} [vs {TOPIC_C}]: What the Community Says (Last 30 Days)` on line 3, then Quick Verdict section +- COMPARISON: badge on line 1, blank line 2, `# {TOPIC_A} vs {TOPIC_B} [vs {TOPIC_C}]: What the Community Says (/Last30Days)` on line 3, then Quick Verdict section --- @@ -128,7 +128,7 @@ These LAWs dominate every other rule in this file. If you find yourself about to **LAW 2 - NO INVENTED TITLE LINE (with COMPARISON exception).** For QUERY_TYPE GENERAL, NEWS, PROMPTING, RECOMMENDATIONS: the first line of your synthesis body (after the badge and one blank line) is the prose label `What I learned:` on its own line. Not `What I learned about {Topic}`, not `{Topic} - Last 30 Days`, not `{Topic}: What People Are Saying`, not `# {Topic}`, not `The headline`, not `Why he is everywhere this month`. Nothing above `What I learned:` except the badge. If you are tempted to write a title or a `##`-prefixed section name, the rule is: the badge IS the title, and section headers are forbidden (see LAW 4). -**COMPARISON exception:** For QUERY_TYPE=COMPARISON (topics containing `vs` or `versus`), the title `# {TOPIC_A} vs {TOPIC_B} [vs {TOPIC_C}]: What the Community Says (Last 30 Days)` is REQUIRED, not a violation. Comparison queries do NOT use the `What I learned:` prose label at all. +**COMPARISON exception:** For QUERY_TYPE=COMPARISON (topics containing `vs` or `versus`), the title `# {TOPIC_A} vs {TOPIC_B} [vs {TOPIC_C}]: What the Community Says (/Last30Days)` is REQUIRED, not a violation. Comparison queries do NOT use the `What I learned:` prose label at all. **Global-preference override:** The skill-authored template for GENERAL / NEWS / PROMPTING / RECOMMENDATIONS queries uses `**bold**` for KEY PATTERNS items and for mid-paragraph lead-ins. Do NOT strip this bold on the grounds of a personal "no bold" memory. The skill's voice contract is the formatting authority here. @@ -570,43 +570,61 @@ Generated: {date} | Sources: Reddit, X, Bluesky, YouTube, TikTok, HN, Polymarket ## If QUERY_TYPE = COMPARISON -When the user asks "X vs Y", run ONE research pass with a comparison-optimized plan that covers both entities AND their rivalry. This replaces the old 3-pass approach (which took 13+ minutes and produced tangential content). +When the user asks "X vs Y" (or "X vs Y vs Z"), the engine fans out N full `pipeline.run()` calls in parallel — one per entity — each with its own Step 0.55-grade targeting. This restored the old N-pass architecture (reverted the one-pass latency optimization that removed per-entity depth); parallel execution keeps wall clock ≈ a single pass. -**IMPORTANT: Include BOTH X handles (`--x-handle={TOPIC_A_HANDLE} --x-related={TOPIC_B_HANDLE},{COMPANY_HANDLES},{COMMENTATOR_HANDLES}`), `--subreddits={RESOLVED_SUBREDDITS}`, `--tiktok-hashtags={RESOLVED_HASHTAGS}`, `--tiktok-creators={RESOLVED_TIKTOK_CREATORS}`, and `--ig-creators={RESOLVED_IG_CREATORS}` from Step 0.55. Omit any flag where the value was not resolved (empty).** +**MANDATORY per-entity resolution.** For each entity, resolve the full Step 0.55 stack (X handle, subreddits, GitHub user/repos, news context). Then assemble a `--competitors-plan` JSON mapping each entity to its targeting, and invoke the engine ONCE with the vs-topic string. -**Single pass with entity-aware subqueries:** +**Output shape per run:** +- Main topic saves to `{main-slug}-raw.md`. +- Each peer saves to `{peer-slug}-raw.md`. +- Stdout shows a merged comparison with the `## Head-to-Head` scaffold + per-entity Resolved Entities block. + +**Invocation:** ```bash -"${LAST30DAYS_PYTHON}" "${SKILL_ROOT}/scripts/last30days.py" "{TOPIC_A} vs {TOPIC_B}" --emit=compact --save-dir="${LAST30DAYS_MEMORY_DIR}" --save-suffix=v3 --plan 'COMPARISON_PLAN_JSON' --x-handle={TOPIC_A_HANDLE} --x-related={TOPIC_B_HANDLE},{COMPANY_A_HANDLE},{COMPANY_B_HANDLE},{COMMENTATOR_HANDLES} --subreddits={RESOLVED_SUBREDDITS} --tiktok-hashtags={RESOLVED_HASHTAGS} --tiktok-creators={RESOLVED_TIKTOK_CREATORS} --ig-creators={RESOLVED_IG_CREATORS} +"${LAST30DAYS_PYTHON}" "${SKILL_ROOT}/scripts/last30days.py" "{TOPIC_A} vs {TOPIC_B} vs {TOPIC_C}" \ + --emit=compact \ + --save-dir="${LAST30DAYS_MEMORY_DIR}" \ + --save-suffix=v3 \ + --x-handle={TOPIC_A_HANDLE} \ + --subreddits={TOPIC_A_SUBS} \ + --competitors-plan '{ + "{TOPIC_B}": {"x_handle":"{TOPIC_B_HANDLE}","subreddits":["{TOPIC_B_SUB_1}","{TOPIC_B_SUB_2}"],"github_user":"{TOPIC_B_GH}","context":"{TOPIC_B_CONTEXT}"}, + "{TOPIC_C}": {"x_handle":"{TOPIC_C_HANDLE}","subreddits":["{TOPIC_C_SUB_1}"],"github_user":"{TOPIC_C_GH}","context":"{TOPIC_C_CONTEXT}"} + }' ``` -**The `--plan` JSON for comparisons should include 3-4 subqueries:** -1. **Head-to-head:** `"{TOPIC_A} vs {TOPIC_B}"` - catches rivalry content, direct comparisons -2. **Entity A news:** `"{TOPIC_A} news {MONTH} {YEAR}"` - catches entity-specific developments -3. **Entity B news:** `"{TOPIC_B} news {MONTH} {YEAR}"` - catches entity-specific developments -4. (Optional) **Domain context:** `"{COMPANY_A} {COMPANY_B} {DOMAIN} news"` - catches industry context (e.g., "OpenAI Anthropic AI news") +Topic A (the main topic, first in the vs-string) uses outer `--x-handle`, `--x-related`, `--subreddits`, `--github-user`, `--github-repo`, `--tiktok-*`, `--ig-creators` as usual. Topics B and C get their targeting from `--competitors-plan` entries (keyed by entity name, case-insensitive). -ALL subqueries include ALL sources. The fusion engine handles deduplication across subqueries. **At least one subquery MUST include YouTube-specific search terms** (e.g., "{PERSON} interview 2026", "{PRODUCT_A} vs {PRODUCT_B} review") to ensure YouTube content is found. Without YouTube-specific terms, the engine may only find 0-1 videos for comparison queries. +**Step 0.55 for N entities.** The same pre-research protocol that applies to a single-entity topic applies to EACH entity in a vs-run. For N=3, that means 3 WebSearches for X handles, 3 for subreddits, 3 for GitHub, 3 for news context — or equivalent batched queries. A `## Resolved Entities` block with dashes for any entity means you skipped Step 0.55 for that one. Re-run with a corrected plan. -Then do WebSearch for: `{TOPIC_A} vs {TOPIC_B} comparison {YEAR}` and `{TOPIC_A} vs {TOPIC_B} which is better` and `{COMPANY_A} vs {COMPANY_B} news {MONTH} {YEAR}`. +**Then do WebSearch supplements** for: `{TOPIC_A} vs {TOPIC_B} comparison {YEAR}` and `{TOPIC_A} vs {TOPIC_B} which is better` — these catch rivalry articles that per-entity passes might not surface. **Skip the normal Step 1 below** - go directly to the comparison synthesis format (see "If QUERY_TYPE = COMPARISON" in the synthesis section). -**COMPARISON TABLE SCAFFOLD (engine-emitted, pass through verbatim):** For comparison topics, the engine's compact output includes a `## Head-to-Head Comparison` block with an empty markdown table (columns = entities, rows = axes like "Core pitch", "Who it's for", "Community stance", "Trajectory") plus a "Choose X if / Choose Y if" prose block. Your synthesis MUST include this block verbatim with filled cells, positioned between the narrative and the emoji-tree footer. Keep each cell to 5-15 words. Use ' - ' (hyphen with spaces) not em-dashes inside cells. The block is the canonical comparison output shape - do not invent your own table structure. +**COMPARISON TABLE SCAFFOLD (engine-emitted, pass through verbatim):** For comparison topics, the engine's compact output includes a `## Head-to-Head` block with an empty markdown table (columns = entities, rows = axes like "What it is", "Community sentiment", "Trajectory"). Your synthesis MUST include this block verbatim with filled cells, positioned between the narrative and the emoji-tree footer. Keep each cell to 5-15 words. Use ' - ' (hyphen with spaces) not em-dashes inside cells. ### Competitor mode (`--competitors`) -When the user passes `--competitors` on a single-entity topic, the engine auto-discovers 1-6 peer entities and fans out the full pipeline over the topic plus each competitor in parallel. Example: `last30days Kanye West --competitors` resolves to a 3-way comparison against Drake and Kendrick Lamar; `last30days OpenAI --competitors=3` resolves against Anthropic, xAI, and Google Gemini. +`--competitors` is a SKILL.md-level shortcut for vs-mode with auto-discovery. The engine flag itself just signals intent; YOU (the hosting reasoning model) do the discovery and Step 0.55 via your own WebSearch tool, then invoke the vs-topic path above. -**Flag surface:** -- `--competitors` (bare) - discover and compare against 2 peers (3-way comparison: original + 2). -- `--competitors=N` - discover N peers (range 1..6; out-of-range clamps with a stderr warning). -- `--competitors-list="A,B,C"` - skip discovery and use the explicit list. Implies `--competitors`. +**The four-step protocol:** +1. **Discover peers** via WebSearch: `"{topic} competitors"` / `"{topic} alternatives"`. Pick N=2 by default (match the flag's default), N=argument value if the user passed `--competitors=N`. +2. **Run Step 0.55 for the main topic AND each peer** — same protocol you use for a single-entity topic, just N times. X handle, subreddits, GitHub, news context, per entity. +3. **Build the vs-topic string**: `"{main} vs {peer1} vs {peer2}"`. +4. **Invoke the engine** with the vs-topic, `--competitors-plan` JSON covering both peers (and the main topic if you want to override the outer flags), and the outer `--x-handle`/`--subreddits`/`--github-*` for the main topic. -**Discovery path:** web search plus deterministic text mining (same backends as `--auto-resolve`). No internal LLM call. When no web search backend is configured and no list is passed, the engine emits a LAW 7-style stderr telling the hosting reasoning model to generate the list and re-invoke with `--competitors-list="A,B,C"`, then exits non-zero. +**Flag surface (engine):** +- `--competitors` (bare) - signals the hosting model to discover 2 peers (3-way total). +- `--competitors=N` - N peers (1..6; out-of-range clamps with stderr warning). +- `--competitors-list="A,B,C"` - minimum escape hatch; names only, no per-entity targeting. Peer sub-runs fall back to planner defaults (visibly thinner data). +- `--competitors-plan '{entity: {x_handle, subreddits, github_user, github_repos, context}}'` - full per-entity targeting; implies vs-mode; preferred. +- `--polymarket-keywords "kw1,kw2"` - disambiguate Polymarket for ambiguous single-token topics ("Warriors" → `nba,gsw,golden-state`). -**Sub-run behavior:** each entity runs `pipeline.run()` in parallel inheriting the main run's `--quick` / `--deep` / `--web-backend` / `--days`. Topic-specific overrides (`--x-handle`, `--subreddits`, `--github-user`, `--github-repo`) apply to the main topic only - competitor sub-runs use planner defaults. Per-entity failures degrade to a warning; the run continues as long as at least 2 entities survive. +**Why --competitors-plan over --competitors-list:** without per-entity handles/subs, peer sub-runs run with deterministic single-word planner queries and produce visibly thinner evidence than the main topic. The Resolved Entities block in stdout makes the gap visible — dashes for a peer = you skipped its Step 0.55. -**Output:** one comparison report covering all entities, reusing the same `## Head-to-Head` scaffold as explicit `A vs B` topics. Synthesis contract identical to the COMPARISON query type above. +**Engine-internal auto-resolve (headless fallback):** if the engine detects BRAVE_API_KEY / EXA_API_KEY / SERPER_API_KEY / PARALLEL_API_KEY / OPENROUTER_API_KEY, it runs its own per-entity `resolve.auto_resolve()` before each sub-run. The hosting-model path does NOT need those keys — you are the WebSearch. The engine's auto-resolve is the cron/CI fallback for when no reasoning model is driving. + +**Output:** one `{slug}-raw.md` per entity in `--save-dir` plus the merged comparison on stdout. Synthesis contract identical to the vs-mode protocol above. --- @@ -1187,7 +1205,7 @@ Voice contract LAWs 1, 3, 5 apply to comparisons unchanged (no `Sources:` block, ``` 🌐 last30days v{VERSION} · synced {YYYY-MM-DD} -# {TOPIC_A} vs {TOPIC_B} [vs {TOPIC_C}]: What the Community Says (Last 30 Days) +# {TOPIC_A} vs {TOPIC_B} [vs {TOPIC_C}]: What the Community Says (/Last30Days) ## Quick Verdict diff --git a/docs/plans/2026-04-22-004-fix-competitors-hosting-model-resolve-and-leak-plan.md.superseded b/docs/plans/2026-04-22-004-fix-competitors-hosting-model-resolve-and-leak-plan.md.superseded new file mode 100644 index 0000000..d76e26b --- /dev/null +++ b/docs/plans/2026-04-22-004-fix-competitors-hosting-model-resolve-and-leak-plan.md.superseded @@ -0,0 +1,394 @@ +--- +title: "fix: --competitors runs a full last30days per entity with hosting-model pre-resolve" +type: fix +status: active +date: 2026-04-22 +origin: docs/plans/2026-04-22-003-fix-competitors-per-entity-resolution-plan.md +--- + +# fix: --competitors runs a full last30days per entity with hosting-model pre-resolve + +## Overview + +User intent confirmed 2026-04-22: `--competitors` should run a full single-entity `last30days` pipeline for the main topic AND for each discovered peer — three independent full-depth passes, each with its own Step 0.55 resolution, own X handle primary weight, own subreddit targeting, own GitHub repo scoping. Then merge them into the comparison output. + +3.0.12 already built the N-parallel-pipelines orchestration (`scripts/lib/fanout.py`). What it got wrong: it tried to do per-entity Step 0.55 engine-side via `resolve.auto_resolve()`, which requires a web search backend key (BRAVE/EXA/SERPER/PARALLEL/OPENROUTER). Matt runs from Claude Code, which has its own WebSearch tool. The engine has none of those keys, so per-entity auto_resolve silently no-ops and all peer sub-runs fall through to deterministic single-word planner queries. + +Four 2026-04-22 test runs (Warriors, Seattle, Arizona Wildcats, Kanye West) confirmed this via engine receipts: + +- Compact Resolved Entities block shows peers as `X - | Subs - | GitHub - | Context: -`. +- Sub-run planner lines show `source=deterministic, subqueries=1` — the "I gave up and keyword-searched" shape. +- Engine footer keeps nudging `💡 You can unlock native grounded web search with BRAVE_API_KEY or SERPER_API_KEY`, which is wrong advice for a Claude Code user who already has WebSearch. +- Kanye run leaked main topic's `--subreddits` into Drake's and Kendrick's sub-runs (regression bug). + +The fix is to flip the resolution responsibility: the hosting model (Claude Code, Codex, Hermes, Gemini) does Step 0.55 via its own WebSearch tool for every entity, then passes the resolved targeting to the engine via a new `--competitors-plan` JSON flag. Engine fan-out remains — each peer still runs a full `pipeline.run()`. The difference is the peers now arrive with full targeting, equivalent to the main topic, so retrieval is apples-to-apples. + +Why not just reuse vs-mode? vs-mode is a SINGLE `pipeline.run()` with a comparison-optimized plan. It pre-resolves Step 0.55 per entity but merges everything into one retrieval pool with lower-weight `--x-related` for peers, merged subreddits, and cross-entity keyword noise. That is not "three full passes." The user explicitly wants three full passes. + +## Problem Frame + +3.0.12's architecture was correct; its data dependency was wrong. + +| Capability | 3.0.12 path | Target path (this plan) | +|---|---|---| +| Fan out to N parallel pipelines | Yes (`fanout.run_competitor_fanout`) | Same — keep | +| Per-entity Step 0.55 resolution | Engine-internal `resolve.auto_resolve()` — needs BRAVE/EXA/SERPER/PARALLEL key | Hosting model does it via its own WebSearch, passes to engine | +| Per-entity targeting threaded into `pipeline.run()` | Main topic only via outer flags; peers via auto_resolve (failing) or nothing | Main topic via outer flags; peers via `--competitors-plan` JSON | +| Footer nudge | Unconditional BRAVE/SERPER | Suppressed when `--plan` or `--competitors-plan` present | +| Resolved Entities block in raw save file | Stdout only | Also in `--save-dir` raw file | +| Override-leak from main into peers | Present (Kanye receipt) | Fixed via explicit per-entity kwargs scrub | +| Polymarket noise on ambiguous topics | Present (Warriors, Arizona receipts) | `--polymarket-keywords` + auto-skip for single-token-ambiguous | + +The key architectural change is who owns per-entity resolution. The engine stops trying to do it itself; the hosting model does it upstream (it already has WebSearch) and passes results in. + +This is the same pattern `--plan` already uses for the main topic: hosting model generates the plan via its own reasoning, passes it in, engine accepts. We apply the pattern to peers. + +## Requirements Trace + +- R1. New `--competitors-plan` JSON flag accepting per-entity targeting: `x_handle`, `x_related`, `subreddits`, `github_user`, `github_repos`, `context`. Implies `--competitors`. Per-entity values thread into that entity's `pipeline.run()`. Bypasses engine-internal `auto_resolve` for covered entities. +- R2. SKILL.md "Competitor mode" rewritten to make the hosting-model path canonical: (a) discover N peers via WebSearch, (b) run Step 0.55 per entity (main + peers) via WebSearch, (c) assemble `--competitors-plan` JSON, (d) invoke engine. Engine-internal auto_resolve remains as headless fallback. +- R3. The LAW 7-style stderr emitted when `--competitors` has no list, no plan, no backend is reframed: leads with "hosting reasoning model, use your WebSearch to run Step 0.55 per entity and pass `--competitors-plan`." Does not lead with BRAVE_API_KEY. +- R4. Footer nudge `💡 You can unlock native grounded web search with BRAVE_API_KEY...` is suppressed when `--plan` OR `--competitors-plan` was passed. Signal: hosting model is driving and already has WebSearch. +- R5. Override-leak fix: competitor sub-runs do not inherit main topic's `--subreddits`, `--x-handle`, `--x-related`, `--tiktok-hashtags`, `--tiktok-creators`, `--ig-creators`, `--github-user`, `--github-repo`. Sub-runs use only their own per-entity targeting (from `--competitors-plan` if provided, else engine-internal auto_resolve if backend, else planner defaults). +- R6. The `## Resolved Entities` block is also appended to the saved raw file when `--save-dir` is in use. Each entity's effective targeting (whatever was actually passed to its `pipeline.run()`) is visible on audit. +- R6b. When `--save-dir` is in use with a comparison run, each entity's sub-run ALSO saves its own standalone raw file — same format as a single-entity run. `/last30days Kanye West --competitors` produces `kanye-west-raw.md`, `drake-raw.md`, `kendrick-lamar-raw.md` (one per entity) plus the merged comparison file. Matches the historical vs-mode behavior when it ran as N passes. +- R7. Polymarket disambiguation: support `--polymarket-keywords "kw1,kw2"` to filter market matches; auto-skip Polymarket when topic is single-token-ambiguous and no override is provided. +- R8. Default `--competitors` count remains 2 (3-way: main + 2 peers). Unchanged from 3.0.12. + +## Scope Boundaries + +- No changes to `scripts/lib/fanout.py` architecture. N parallel pipelines stays. Only the data each sub-run receives changes. +- No changes to the vs-mode (topic contains "vs" / "versus") behavior. That path is independent. +- No new emit modes. Comparison output format unchanged. +- No deprecation of `--competitors-list`. Stays as the minimum escape hatch for hosting models that skip per-entity Step 0.55 (names-only). + +### Deferred to Separate Tasks + +- Cache layer for hosting-model competitor resolution: separate plan once cost evidence exists. +- Cross-source disambiguation beyond Polymarket: separate plan. + +## Context & Research + +### Relevant Code and Patterns + +- `scripts/last30days.py` — `--competitors` / `--competitors-list` argparse block, `resolve_competitors_args` validator, `_main_runner` closure, `_competitor_runner` closure, the `[Competitors] --competitors requires...` stderr block. Primary file for this plan. +- `scripts/lib/fanout.py` — `run_competitor_fanout` orchestrator. Signature unchanged; `_competitor_runner` closure now builds kwargs from `--competitors-plan`. +- `scripts/lib/pipeline.py` — `pipeline.run()` signature; no changes required (all per-entity flags already exist as kwargs). +- `scripts/lib/planner.py` — existing `--plan` parsing and validation, pattern to mirror for `--competitors-plan`. +- `scripts/lib/render.py` `_render_resolved_entities_block` (added in 3.0.12) — already reads `report.artifacts["resolved"]`; no change needed. +- `scripts/last30days.py` `save_output` / `render.render_full` — the save path. Needs to include the Resolved Entities block for comparison runs. +- `scripts/lib/quality_nudge.py` — where the BRAVE/SERPER footer nudge is emitted. Needs a context-aware suppression check. +- `scripts/lib/polymarket.py` — source adapter. Entry point for `--polymarket-keywords` filter and single-token-ambiguous auto-skip. + +### Institutional Learnings + +- 3.0.11 plan (`2026-04-22-002`): built the initial fanout, deferred per-entity resolve as "v1 simplification." +- 3.0.12 plan (`2026-04-22-003`): tried to close the gap via engine-internal `auto_resolve`. Works only with backend keys. Fails silently without. +- 2026-04-22 test session receipts: confirmed all four fixes in this plan are real, reproducible bugs. +- User's architectural steer 2026-04-22: "runs a full last30days on all 3 topics" — this plan encodes that explicitly as N full `pipeline.run()` calls with pre-resolved targeting per entity. + +### External References + +- None. All patterns in-repo. + +## Key Technical Decisions + +- **`--competitors-plan` is a single JSON flag, not a fan of separate flags.** Mirrors `--plan`. Stable schema: `{entity_name: {x_handle, x_related, subreddits, github_user, github_repos, context}}`. Accept inline JSON or a file path (matches `--plan`). +- **Hosting-model-driven resolution is the documented default.** Engine-internal `auto_resolve` is the headless / cron fallback. SKILL.md routes hosting models to the JSON-flag path; engine keeps auto_resolve alive for BRAVE/EXA/SERPER users running CI. +- **Override-leak fix is call-site scrubbing, not a signature change.** `_competitor_runner` builds an explicit kwargs dict per entity from `_subrun_kwargs(entity, plan_entry)`. No closure-default fallthrough from main scope. The 3.0.12 `entity_config = dict(config)` deep-copy pattern extends to every per-entity flag. +- **Footer nudge becomes context-aware.** Suppressed when `--plan` or `--competitors-plan` present. Not suppressed for bare `--competitors-list` or bare invocations. Headless cron without keys still sees the nudge. +- **Polymarket disambiguation is additive and conservative.** `--polymarket-keywords` is explicit; auto-skip only fires for a known list of single-token-ambiguous names (states, common nouns). Stderr notes the skip so it is observable and overridable. +- **Per-entity sub-runs get the full `pipeline.run()` pass.** Same depth, same sources, same API cost per entity as a single-topic run. This is the explicit user intent — three full passes, not one merged pass. + +## Open Questions + +### Resolved During Planning + +- **JSON or multi-flag?** JSON. Matches `--plan`. +- **Default count?** 2 peers (3-way comparison). Unchanged from 3.0.12. +- **Does engine-internal auto_resolve stay alive?** Yes, for entities not covered by `--competitors-plan` when a backend is configured. Headless/cron users with keys keep the current 3.0.12 behavior. +- **vs-mode or fanout?** Fanout. User's explicit ask: three full passes, not one merged pass. vs-mode merges into one pipeline with lower peer weighting, which is not what the user wants. +- **Does the save file need per-entity clusters?** Start with the Resolved block appended. Per-entity cluster sections can follow in a separate task; they are nice-to-have, not blocking. + +### Deferred to Implementation + +- Exact trace of override-leak source. Candidates: closure capture of `subreddits` in `_competitor_runner`, shared `_auto_resolve_context` leak, Reddit adapter inheriting global config. Test-first; trace at implementation time. +- Heuristic for "single-token-ambiguous topic" auto-skip. Start with a short hard-coded list (US state names, US city names, common nouns like "Warriors", "Suns", "Jets"); revisit after dogfood. +- Whether per-entity coverage warnings fire when `--competitors-plan` under-resolves an entity (e.g., only `x_handle`, no subreddits). Start with stderr logging; revisit UX. + +## Implementation Units + +- [ ] **Unit 1: `--competitors-plan` JSON flag + per-entity kwargs threading** + +**Goal:** New CLI flag accepting per-entity targeting JSON. Each covered entity's `pipeline.run()` receives its own `x_handle` / `x_related` / `subreddits` / `github_user` / `github_repos` / `context`. Skips engine-internal `auto_resolve` for covered entities. + +**Requirements:** R1, R5 (primary leak fix site) + +**Dependencies:** None + +**Files:** +- Modify: `scripts/last30days.py` (argparse + parse + `_competitor_runner`) +- Possibly modify: `scripts/lib/fanout.py` (no signature change expected; verify) +- Test: `tests/test_cli_competitors.py` (extend) +- Test: `tests/test_competitors_plan_threading.py` (new) + +**Approach:** +- Add `--competitors-plan` argparse flag. Accepts inline JSON OR a file path (mirror `--plan`). +- Validation: parse JSON; must be a dict; each value must be a dict; unknown fields log warnings; malformed input exits 2. +- Schema per entity: optional fields `x_handle` (str), `x_related` (list), `subreddits` (list), `github_user` (str), `github_repos` (list), `context` (str). +- Case-insensitive matching against `--competitors-list` / discovered entities. +- Build `_subrun_kwargs(entity, plan_entry)` helper. Returns a complete, explicit kwargs dict for `pipeline.run()` with no closure-default fallthrough from main scope. This helper is the single source of truth for per-entity call args. It also fixes the override-leak (R5) by scrubbing all per-entity flags to None unless the plan (or auto_resolve) sets them. +- `_competitor_runner(entity)`: + 1. Look up `plan_entry` from `--competitors-plan` (if any). + 2. If plan covers entity fully, build kwargs from it; skip `auto_resolve`. + 3. If plan partially covers or is absent, fall back to `auto_resolve` (3.0.12 behavior) when a backend is configured. Plan values win over auto_resolve values on conflict. + 4. If neither plan nor backend, fall through to `pipeline.run()` with per-entity kwargs all None — engine uses planner defaults for that entity only (no leak). +- Deep-copy config per sub-run (already done in 3.0.12); merge per-entity `context` into `entity_config["_auto_resolve_context"]` only. + +**Execution note:** Test-first for the override-leak regression (pass `--subreddits=A,B` on main + a peer, assert peer's `pipeline.run(subreddits=...)` is None or peer-specific). + +**Patterns to follow:** +- `--plan` parsing at `scripts/last30days.py` (inline JSON or file path). +- 3.0.12's `_competitor_runner` closure for scope; extract the kwargs-build into `_subrun_kwargs` helper. +- `entity_config = dict(config)` deep-copy pattern from 3.0.12. + +**Test scenarios:** +- Happy path: `--competitors-plan '{"Drake": {"x_handle":"Drake","subreddits":["Drizzy"]}}'` → Drake's `pipeline.run` receives `x_handle="Drake"` and `subreddits=["Drizzy"]`; no `auto_resolve` call for Drake. +- Happy path: plan covers 2 of 3 entities, backend configured → covered entities skip auto_resolve; third falls back to auto_resolve. +- Happy path: plan file path accepted like `--plan` file path. +- Happy path: case-insensitive entity match (`Drake` in plan, `drake` in list). +- Edge case: unknown fields in plan entry → logged, ignored, run continues. +- Edge case: plan entry for entity not in list → ignored with warning. +- Error path: malformed JSON → exit 2. +- Error path: top-level JSON is list not dict → exit 2. +- Regression (leak fix): main `--subreddits=A,B` + `--competitors-list "Drake"` + no plan → Drake's `pipeline.run` receives `subreddits=None` (no leak). +- Regression (leak fix): same for `--x-handle`, `--x-related`, `--tiktok-*`, `--ig-creators`, `--github-*`. +- Regression (leak fix): main `--x-handle=kanyewest` + plan `{"Drake":{"x_handle":"Drake"}}` → Drake's sub-run gets `x_handle="Drake"`, NOT `"kanyewest"`. +- Integration: full main + 2 peers run via `--competitors-plan`; assert each sub-run's effective kwargs match expected per-entity values. + +**Verification:** +- All new and regression tests pass. +- Smoke run (mock mode + `--competitors-plan`): stderr shows `[Competitors] Drake: x=@Drake subs=Drizzy` line per entity; no `[AutoResolve]` calls for plan-covered entities; no leak of main topic's flags. + +- [ ] **Unit 2: Reframe LAW 7-style stderr for hosting-model context** + +**Goal:** When `--competitors` has no `--competitors-list`, no `--competitors-plan`, and no backend, stderr tells the hosting reasoning model to use its WebSearch tool for Step 0.55 per entity and pass `--competitors-plan`. Stops leading with BRAVE_API_KEY. + +**Requirements:** R3 + +**Dependencies:** Unit 1 (flag must exist) + +**Files:** +- Modify: `scripts/last30days.py` (the existing `[Competitors] --competitors requires...` block) +- Test: `tests/test_competitors_no_backend_message.py` (new) + +**Approach:** +- Rewrite stderr in this order: + 1. "If you are the hosting reasoning model (Claude Code, Codex, Hermes, Gemini, or any agent runtime with a WebSearch tool), YOU should: (a) discover N peers via WebSearch, (b) run Step 0.55 per entity (main + peers), (c) assemble a `--competitors-plan` JSON, (d) re-invoke. Skip this step and quality degrades — peer entities will run with planner defaults." + 2. "If you are running headless (cron, CI, no hosting model), set BRAVE_API_KEY / EXA_API_KEY / SERPER_API_KEY / PARALLEL_API_KEY / OPENROUTER_API_KEY and re-run." + 3. "Minimum escape hatch: `--competitors-list "A,B,C"` skips discovery but does not pre-resolve peers. Use only for quick tests." +- Exits non-zero as today. + +**Patterns to follow:** +- Existing LAW 7 stderr in `planner.plan_query` for tone. + +**Test scenarios:** +- Happy path: stderr leads with "If you are the hosting reasoning model" and names `--competitors-plan` before any backend key. +- Happy path: stderr explicitly names `--competitors-plan` as the preferred override. +- Happy path: stderr does NOT say "requires either a configured web search backend OR an explicit --competitors-list" (the current 3.0.12 wording). + +**Verification:** +- Test asserts ordering and required phrases. + +- [ ] **Unit 3: Suppress BRAVE/SERPER footer nudge when hosting-model-driven** + +**Goal:** The `💡 You can unlock native grounded web search with BRAVE_API_KEY or SERPER_API_KEY` footer is suppressed when `--plan` or `--competitors-plan` was passed (signal: hosting model is driving and already has WebSearch). + +**Requirements:** R4 + +**Dependencies:** Unit 1 + +**Files:** +- Modify: `scripts/lib/quality_nudge.py` (or wherever nudge is emitted; verify during implementation) +- Test: `tests/test_footer_nudge_suppression.py` (new) + +**Approach:** +- Locate the nudge emission point. +- Add a suppression check: if `--plan` OR `--competitors-plan` was passed, skip the nudge. Otherwise, current behavior. +- Don't suppress the nudge for bare `--competitors-list` alone — that path isn't necessarily hosting-model-driven. + +**Test scenarios:** +- Happy path: `--plan` passed, no backend → nudge does NOT fire. +- Happy path: `--competitors-plan` passed, no backend → nudge does NOT fire. +- Happy path: `--competitors-list` only, no backend → nudge fires (current behavior). +- Happy path: no `--competitors`, no `--plan`, no backend → nudge fires (current behavior unchanged). + +**Verification:** +- All four scenarios produce expected nudge presence/absence. + +- [ ] **Unit 4: Per-entity save files + Resolved block in each** + +**Goal:** When `--save-dir` is in use with a comparison run, each entity's sub-run saves its own standalone raw file (same format as a single-entity run), and each file includes the `## Resolved Entities` block so audits can see what targeting that entity received. Matches the historical vs-mode behavior when it was N passes. + +**Requirements:** R6, R6b + +**Dependencies:** Unit 1 + +**Files:** +- Modify: `scripts/last30days.py` (`save_output`, the save loop after fanout completes) +- Possibly modify: `scripts/lib/render.py` (`render_full` branch to include Resolved block when artifact is present) +- Test: `tests/test_save_raw_competitor_files.py` (new) + +**Approach:** +- After fanout completes, iterate `report.artifacts["competitor_reports"]`. For each `(entity, entity_report)` tuple, call `save_output(entity_report, emit="md", save_dir=args.save_dir, suffix=args.save_suffix)` — same path a single-entity run takes. +- Each saved file uses its entity's slug as the filename (`drake-raw.md`, `kendrick-lamar-raw.md`). Main topic keeps the existing `kanye-west-raw.md` filename. +- Each file includes its own `## Resolved Entities` block (single-entity variant: one row for that entity only). This makes each sub-run's file self-describing — you can see what targeting was used without opening the comparison file. +- The merged comparison output (stdout) still includes the 3-row Resolved Entities block. +- Optional: also save a comparison summary file (e.g., `kanye-west-comparison-raw.md`) holding the merged multi-entity render. Start with per-entity files only; comparison summary is a follow-up if stdout-plus-individual-files is insufficient. +- Single-entity runs unchanged (no additional files, no block change). + +**Patterns to follow:** +- Existing `save_output` invocation for single-entity runs (line 501 of current `scripts/last30days.py`). +- Existing slug generation (`slugify(topic)`) for filename consistency. +- `_render_resolved_entities_block` from 3.0.12 for the single-entity variant. + +**Test scenarios:** +- Happy path: `--competitors-list "Drake,Kendrick Lamar"` + `--save-dir=/tmp/x` → `/tmp/x/kanye-west-raw.md`, `/tmp/x/drake-raw.md`, `/tmp/x/kendrick-lamar-raw.md` all exist. +- Happy path: each peer file's first sections include that entity's Resolved Entities block with its own row only. +- Happy path: single-entity run with `--save-dir` → one file, unchanged from today's behavior. +- Edge case: entity slug collides with existing file → overwrite (matches single-entity behavior). +- Edge case: `--save-suffix=v3` → all 3 files get the suffix (`kanye-west-raw-v3.md`, `drake-raw-v3.md`, `kendrick-lamar-raw-v3.md`). +- Edge case: comparison run with one peer whose sub-run failed → that entity's file is NOT saved; others are. +- Integration: stderr after save shows three `[last30days] Saved output to ` lines, one per entity. + +**Verification:** +- After `/last30days Kanye West --competitors-list "Drake,Kendrick Lamar" --save-dir=/tmp/x`: `ls /tmp/x/*-raw.md` shows 3 files. Each contains its entity's Resolved block. + +- [ ] **Unit 5: SKILL.md "Competitor mode" rewrite — hosting-model Step 0.55 canonical** + +**Goal:** SKILL.md documents the hosting-model-driven path as canonical: discover N peers via WebSearch, run Step 0.55 per entity, assemble `--competitors-plan`, invoke engine. Engine-internal `auto_resolve` is labeled the headless fallback. + +**Requirements:** R2 + +**Dependencies:** Unit 1 (flag must exist before documented) + +**Files:** +- Modify: `SKILL.md` (Competitor mode subsection) +- Modify: `README.md` (one-line example update) + +**Approach:** +- Replace the 3.0.12 Competitor mode subsection with a clear flow: + 1. User invokes with `--competitors` or `--competitors=N`. + 2. Hosting model runs WebSearch for "[topic] competitors" / "[topic] alternatives" → picks top N peers. + 3. Hosting model runs Step 0.55 for main + each peer (x_handle, subreddits, github_user, github_repos, context) — same protocol as vs-mode per SKILL.md §679. + 4. Hosting model assembles a `--competitors-plan` JSON object. + 5. Hosting model invokes the engine with `--competitors-list "A,B,C" --competitors-plan '{...}'`. + 6. Engine fans out N full pipelines (main + peers), each with its own full Step 0.55-grade targeting. Each entity also saves its own `*-raw.md` file when `--save-dir` is set (three full passes → three save files, matching the historical vs-mode behavior). Comparison output merges them for display. +- Concrete JSON example in SKILL.md showing the schema. +- Failure-mode warning: a `## Resolved Entities` block with dashes for any entity means hosting model skipped Step 0.55 for that one. Re-run with corrected plan. +- "Headless fallback" sub-subsection: when BRAVE/EXA/SERPER/PARALLEL/OPENROUTER is set, engine's internal `auto_resolve` handles peers and `--competitors-plan` is optional. + +**Patterns to follow:** +- SKILL.md "Step 0.55" section for per-entity resolve protocol. +- SKILL.md "If QUERY_TYPE = COMPARISON" section for the same-protocol-as-vs-mode reference. +- Tone of existing 3.0.12 Competitor mode prose. + +**Test scenarios:** +- Test expectation: none — documentation. Verification is a fresh Claude Code window dogfood run. + +**Verification:** +- `/last30days Kanye West --competitors` in a new window: hosting model does Step 0.55 for Kanye + 2 discovered peers; passes `--competitors-plan`; rendered Resolved block shows non-empty fields for all 3; top voices include at least one peer-specific handle. + +- [ ] **Unit 6: Polymarket disambiguation guard** + +**Goal:** Support `--polymarket-keywords "kw1,kw2"` to filter market matches; auto-skip Polymarket when topic is single-token-ambiguous and no override is provided. + +**Requirements:** R7 + +**Dependencies:** None + +**Files:** +- Modify: `scripts/last30days.py` argparse (`--polymarket-keywords`) +- Modify: `scripts/lib/polymarket.py` +- Test: `tests/test_polymarket_disambiguation.py` (new) + +**Approach:** +- Add `--polymarket-keywords "kw1,kw2"` flag. When provided, Polymarket adapter filters market titles to those whose normalized text contains at least one keyword. +- Auto-skip rule: if topic is one token AND token matches a known-ambiguous list (US state names, US city names, common sports/color/animal words) AND no `--polymarket-keywords` provided, skip Polymarket with a stderr note. +- SKILL.md Step 0.55 protocol gets a small addition: for ambiguous topics, hosting model passes `--polymarket-keywords` with topic-specific qualifiers. + +**Patterns to follow:** +- Existing Polymarket adapter match logic. +- Single-token detection heuristic. + +**Test scenarios:** +- Happy path: topic "Warriors", no override → Polymarket skipped; stderr notes the skip. +- Happy path: topic "Warriors", `--polymarket-keywords "nba,gsw"` → Polymarket runs; matches filtered. +- Happy path: topic "OpenAI" (no ambiguity) → Polymarket runs as before. +- Happy path: topic "Arizona Wildcats" (multi-token) → Polymarket runs as before. +- Edge case: `--polymarket-keywords ""` → treated as empty, no filter. + +**Verification:** +- Warriors smoke run → Polymarket footer absent OR filtered to nba/gsw markets. + +- [ ] **Unit 7: Version 3.0.13, CHANGELOG, sync, hot-copy** + +**Goal:** Ship 3.0.13 to all local targets. + +**Requirements:** Closes R1-R7 + +**Dependencies:** Units 1-6 + +**Files:** +- Modify: `.claude-plugin/plugin.json` +- Modify: `CHANGELOG.md` +- Run: `bash scripts/sync.sh` +- Hot-copy: `~/.claude/plugins/cache/last30days-skill/last30days/3.0.13/` + +**Approach:** +- CHANGELOG entry groups the fixes: Added `--competitors-plan` JSON flag for per-entity hosting-model pre-resolve. Fixed override-leak from main into peer sub-runs. Changed: LAW 7 stderr framing for hosting-model context. Changed: BRAVE/SERPER footer nudge suppressed when `--plan` / `--competitors-plan` is present. Added: Resolved Entities block persists to saved raw file. Added: `--polymarket-keywords` + auto-skip for ambiguous single-token topics. +- Beta channel first per CLAUDE.md. +- Hot-copy so public `/last30days` picks up 3.0.13 immediately. + +**Test scenarios:** +- Test expectation: none — packaging. + +**Verification:** +- `grep version .claude-plugin/plugin.json` returns 3.0.13. +- `sync.sh` exits 0. +- Hot-copy contains the new files with competitors.py, fanout.py, the updated SKILL.md, and plugin.json 3.0.13. + +## System-Wide Impact + +- **Interaction graph:** `_competitor_runner` becomes the single source of truth for sub-run kwargs via `_subrun_kwargs(entity, plan_entry)`. Every per-entity flag flows through one helper. No closure-default leaks. +- **Error propagation:** `--competitors-plan` JSON parse errors exit 2 with stderr (same as `--plan`). Per-entity plan entries with malformed values log warnings and fall back; don't abort the whole run. +- **State lifecycle risks:** `entity_config = dict(config)` already deep-copies for `_auto_resolve_context`; extend the isolation discipline to every per-entity flag. Verified in Unit 1 regression tests. +- **API surface parity:** `--competitors-plan` is additive. `--competitors` and `--competitors-list` unchanged. `--plan` unchanged. `--polymarket-keywords` additive. +- **Integration coverage:** New regression tests for override-leak. New integration test for plan-driven sub-run threading. New nudge-suppression test. New Polymarket disambiguation test. +- **Unchanged invariants:** `pipeline.run()` signature unchanged. `planner.plan_query` LAW 7 behavior for the default path unchanged. Single-entity render path unchanged. vs-mode behavior unchanged. + +## Risks & Dependencies + +| Risk | Mitigation | +|------|------------| +| Hosting model takes the lazy path and uses `--competitors-list` names-only. | Unit 2 stderr explicitly steers to `--competitors-plan` with Step 0.55 protocol named. Unit 5 SKILL.md docs. Resolved Entities dashes in output make the gap visible. | +| JSON gets verbose for the hosting model to construct repeatedly. | Schema is small (≤6 fields per entity). Hosting model already runs Step 0.55 for main topic in every comparison run; peers use the same protocol. One JSON block replaces N CLI flags. | +| Override-leak source is deeper than `_competitor_runner` closure. | Test-first per Unit 1. Receipts from 2026-04-22 Kanye run are reproducible. Trace methodically from call site. | +| Plan-covered entity bypasses auto_resolve but plan data is incomplete (e.g., no subreddits). | Hosting model's own SKILL.md contract says Step 0.55 must cover all fields. Stderr logs per-entity coverage so under-resolved entities are visible. Next-run correction, not engine-side rescue. | +| Polymarket auto-skip false-positives on legitimate ambiguous topics with real markets. | Conservative match (single-token + known list). `--polymarket-keywords` override is explicit and unambiguous. Stderr notes the skip. | +| Footer nudge suppression hides the message from headless users who genuinely need it. | Suppression only fires when `--plan` or `--competitors-plan` is present. Cron / CI runs that pass neither still see the nudge. | + +## Documentation / Operational Notes + +- Beta channel first per CLAUDE.md (private repo `/last30days-beta`). +- After merge: hot-copy to `~/.claude/plugins/cache/last30days-skill/last30days/3.0.13/`. +- CHANGELOG voice should call this out as the feedback-driven follow-up to 3.0.12. Reader should see "we tried engine-internal resolve in 3.0.12; it needs backend keys we don't have; we moved resolution to the hosting model in 3.0.13." + +## Sources & References + +- Origin plan (3.0.12): `docs/plans/2026-04-22-003-fix-competitors-per-entity-resolution-plan.md` +- Earlier plan (3.0.11): `docs/plans/2026-04-22-002-feat-competitors-flag-comparison-fanout-plan.md` +- 2026-04-22 test session receipts: Warriors, Seattle, Arizona Wildcats, Kanye West +- SKILL.md §551 "If QUERY_TYPE = COMPARISON" and §679 per-entity Step 0.55 protocol +- Related code: `scripts/lib/fanout.py`, `scripts/last30days.py` `_competitor_runner`, `scripts/lib/render.py` `_render_resolved_entities_block`, `scripts/lib/polymarket.py`, `scripts/lib/quality_nudge.py` +- Related PRs: #308 (3.0.11), #309 (3.0.12) diff --git a/docs/plans/2026-04-22-005-feat-vs-mode-n-passes-competitors-auto-discovery-plan.md b/docs/plans/2026-04-22-005-feat-vs-mode-n-passes-competitors-auto-discovery-plan.md new file mode 100644 index 0000000..88b0479 --- /dev/null +++ b/docs/plans/2026-04-22-005-feat-vs-mode-n-passes-competitors-auto-discovery-plan.md @@ -0,0 +1,451 @@ +--- +title: "feat: vs mode runs N full passes and --competitors is vs with auto-discovery" +type: feat +status: active +date: 2026-04-22 +origin: docs/plans/2026-04-22-004-fix-competitors-hosting-model-resolve-and-leak-plan.md.superseded +--- + +# feat: vs mode runs N full passes and --competitors is vs with auto-discovery + +## Overview + +Architectural unification driven by user correction 2026-04-22: vs mode and `--competitors` are the same thing. A user typing `/last30days OpenAI vs Anthropic vs xAI` should get a full single-entity last30days pass for each of the three entities — three full pipelines, three saved `*-raw.md` files, merged into one comparison output. A user typing `/last30days OpenAI --competitors` should get the same output after the hosting model auto-picks 2 peers; i.e., `--competitors` is a thin shortcut that expands "topic + `--competitors`" into "topic vs peer1 vs peer2" and then runs the unified vs pipeline. + +Current state diverges from this: + +- **vs mode today**: one `pipeline.run()` with a comparison-optimized plan that merges all entities' targeting into a single retrieval pool. Lower-weight `--x-related` for peers, merged subreddits, cross-entity keyword noise. One saved file. +- **`--competitors` today (3.0.12)**: N parallel `pipeline.run()` calls via `scripts/lib/fanout.py`, but per-entity Step 0.55 depends on an engine-side web backend key Matt doesn't have. Silently degrades to planner defaults for peers. One saved file (main topic only). Override-leak from main into peers. + +After this plan: + +- **vs mode**: N parallel `pipeline.run()` calls, one per entity, each with its own full Step 0.55-grade targeting, each saving its own `*-raw.md`. Merged into one comparison output. +- **`--competitors`**: SKILL.md shortcut. Hosting model discovers N peers, builds `"topic vs peer1 vs peer2"`, and invokes the same vs pipeline. No separate orchestration path. +- **Same fanout machinery (`scripts/lib/fanout.py`)** serves both. One fix, both behaviors improve. + +## Problem Frame + +The product insight from 2026-04-22 test runs is simple: the user wants three full last30days reports plus a comparison merge. Not one comparison pass with N-way targeting merged into a single retrieval pool. Not one save file. Not "main gets Step 0.55, peers get planner defaults." Three full passes. Three save files. Merged output. + +The historical vs mode did that (it ran as 3 passes, saving 3 files). SKILL.md §551 currently says: + +> "When the user asks 'X vs Y', run ONE research pass with a comparison-optimized plan that covers both entities AND their rivalry. This replaces the old 3-pass approach (which took 13+ minutes and produced tangential content)." + +That change was a latency optimization that removed the user-visible behavior the user wants. The fix is to revert the architectural direction: N passes per entity, in parallel rather than serial (parallelism lowers wall-clock to ~1× a single pass, not N×), with per-entity save files. + +The 3.0.11 `--competitors` flag already introduced parallel N-pass machinery (`fanout.run_competitor_fanout`). The 3.0.12 follow-up tried to wire per-entity Step 0.55 into it but failed when no web backend was configured. The elegant move: stop maintaining two architectures. vs-mode and `--competitors` both use `fanout.py`. `--competitors` becomes a SKILL.md-level shortcut that discovers 2 peers and hands off to vs-mode. + +Four 2026-04-22 test receipts (Warriors, Seattle, Arizona Wildcats, Kanye West) all confirmed the user's pain points: + +- Peers thin because they ran without per-entity handle/sub targeting. +- Only one `*-raw.md` per run — no per-entity audit. +- Kanye peers leaked main topic's `--subreddits`. +- Engine footer nudging `BRAVE_API_KEY` to Claude Code users who already have WebSearch. +- Polymarket noise on ambiguous topics (Warriors → Glasgow rugby; Arizona → Diamondbacks). + +This plan closes all of them by unifying the architecture and making hosting-model-driven Step 0.55 per entity the canonical path. + +## Requirements Trace + +- R1. vs mode (any topic containing ` vs ` / ` versus `) runs N full `pipeline.run()` calls in parallel, one per entity. Each sub-run uses its entity's own Step 0.55 targeting (from the hosting model's pre-resolution, passed via a new `--competitors-plan` JSON). +- R2. `--competitors` (and `--competitors=N`) becomes a SKILL.md-level shortcut: the hosting model (a) discovers N peers via WebSearch, (b) runs Step 0.55 per entity (main + peers), (c) rewrites the topic to `"main vs peer1 vs peer2"`, (d) invokes the engine with `--competitors-plan` containing each entity's targeting. +- R3. New `--competitors-plan` JSON flag. Schema: `{entity_name: {x_handle, x_related, subreddits, github_user, github_repos, context}}`. Implies vs mode when present with a single-entity topic. Applies per-entity targeting to each sub-run. Accepts inline JSON or a file path (matches `--plan`). +- R4. Each entity's sub-run saves its own `*-raw.md` file when `--save-dir` is in use. Example: `/last30days "Kanye West vs Drake vs Kendrick Lamar" --save-dir=~/Documents/Last30Days` produces `kanye-west-raw.md`, `drake-raw.md`, `kendrick-lamar-raw.md`. Same filenames a single-entity run of each topic would produce. Matches historical vs-mode behavior. +- R5. Each per-entity saved file includes its own single-row `## Resolved Entities` block so the audit survives. The merged comparison stdout still shows the full 3-row block. +- R6. Override-leak fix: no main-topic flags (`--subreddits`, `--x-handle`, `--x-related`, `--tiktok-*`, `--ig-creators`, `--github-*`) leak into peer sub-runs. Every per-entity kwarg is scrubbed at the sub-run call site. +- R7. LAW 7-style stderr for `--competitors` invocations with no list, no plan, no backend is reframed for hosting-model context: leads with "use your WebSearch to discover peers, resolve Step 0.55 per entity, re-invoke with `topic vs peer1 vs peer2 --competitors-plan '...'`." Does not lead with BRAVE_API_KEY. +- R8. Footer nudge `💡 You can unlock native grounded web search with BRAVE_API_KEY...` is suppressed when `--plan` or `--competitors-plan` was passed. +- R9. Polymarket disambiguation: support `--polymarket-keywords "kw1,kw2"` to filter market matches; auto-skip Polymarket when topic is single-token-ambiguous and no override is provided. +- R10. Default `--competitors` count stays 2 peers (3-way comparison). Unchanged from 3.0.12. + +## Scope Boundaries + +- No changes to single-entity `pipeline.run()` semantics. Each sub-run in vs mode behaves identically to a bare `/last30days {entity}` invocation. +- No changes to the planner's comparison-intent logic for single-entity-containing topics. The `_should_force_deterministic_plan` shortcut for vs-topics routes to fanout, not to its current single-pipeline path. +- No new emit modes. Comparison output format unchanged. +- No removal of `--competitors-list`. Stays as a minimum escape hatch (names-only, no per-entity targeting) for scripted headless use. +- No removal of engine-internal `resolve.auto_resolve()` in fanout. Remains as headless / cron fallback for users with BRAVE/EXA/SERPER/PARALLEL/OPENROUTER keys. The dominant Claude Code path bypasses it via `--competitors-plan`. + +### Deferred to Separate Tasks + +- Explicit "head-to-head" rivalry pass in vs-mode (a supplemental subquery like `"A vs B"` that catches rivalry articles missing from pure entity-scoped passes). Start with N independent passes; add a head-to-head supplemental pass if the rivalry-content gap shows up in dogfood. +- Cache layer for hosting-model pre-resolution. +- Cross-source disambiguation (not just Polymarket). +- Latency knob for users who want the old one-pass vs behavior (probably not needed; parallel N-pass is ~1× wall clock). + +## Context & Research + +### Relevant Code and Patterns + +- `scripts/last30days.py` — main(), `_main_runner`, `_competitor_runner`, the competitor enable/discovery branch. Primary file. +- `scripts/lib/fanout.py` — existing orchestrator (3.0.11). Reused as-is; `competitor_runner` closure is where per-entity kwargs apply. +- `scripts/lib/planner.py` — `_should_force_deterministic_plan` detects vs-topics via regex. Current path synthesizes ONE comparison plan; new path routes to fanout. +- `scripts/lib/render.py` — `render_comparison_multi` (3.0.12) + `_render_resolved_entities_block`. Both reused. `render_full` needs a per-entity variant when saving sub-run files. +- `scripts/last30days.py` `save_output` — where raw files are written. Needs to iterate per entity when competitor_reports artifact present. +- `scripts/lib/quality_nudge.py` — BRAVE/SERPER nudge emission. +- `scripts/lib/polymarket.py` — source adapter for `--polymarket-keywords` and ambiguous-topic auto-skip. +- SKILL.md §551 "If QUERY_TYPE = COMPARISON" and §679 per-entity Step 0.55 protocol — the hosting-model contract that drives per-entity pre-resolution for both vs mode and `--competitors`. + +### Institutional Learnings + +- 3.0.11 plan (`2026-04-22-002`): built fanout. +- 3.0.12 plan (`2026-04-22-003`): tried engine-internal per-entity auto_resolve; failed without backend keys. +- 3.0.13 plan draft (`2026-04-22-004-...superseded`): proposed `--competitors-plan` JSON + vs-mode-shortcut path but kept them separate. User's 2026-04-22 correction unifies them. +- 2026-04-22 test receipts: Warriors, Seattle, Arizona Wildcats, Kanye West runs all reproduced the per-entity resolve gap. +- User's architectural steer: "vs mode should work that way too" + "--competitors is just vs mode with auto-discovery." This plan encodes that. + +### External References + +- None. All patterns in-repo. + +## Key Technical Decisions + +- **Unify vs-mode and --competitors on one orchestrator.** `fanout.run_competitor_fanout` serves both. vs-mode is "topic contains ' vs '" detection → fanout. `--competitors` is "SKILL.md shortcut → hosting model rewrites topic to vs form → fanout." One code path. +- **Per-entity targeting via `--competitors-plan` JSON.** Schema `{entity_name: {x_handle, x_related, subreddits, github_user, github_repos, context}}`. Mirrors `--plan`. Applies to both vs-mode and `--competitors` paths. Hosting model passes it after running Step 0.55 per entity. +- **N save files, one per entity.** Each sub-run writes a `{entity-slug}-raw.md` file when `--save-dir` is set. Matches historical vs-mode behavior. Single-entity runs unchanged. +- **Revert the "one pass for latency" optimization that removed per-entity passes.** Parallel execution via `ThreadPoolExecutor` means wall-clock is ~max(per-entity-latency), not sum. The old latency concern (13+ minutes for 3 serial passes) does not apply to a parallel fan-out. +- **Override-leak fix at the call site.** `_subrun_kwargs(entity, plan_entry)` helper returns fully explicit per-entity kwargs; no closure-default fallthrough from main scope. +- **LAW 7 stderr reframed, not just updated.** Current message treats BRAVE_API_KEY as the solution. New message treats hosting-model Step 0.55 as the solution, with backend keys listed only as the headless fallback. +- **Polymarket disambiguation is additive and conservative.** `--polymarket-keywords` is explicit; auto-skip only fires for a known-ambiguous single-token list. + +## Open Questions + +### Resolved During Planning + +- **vs mode N passes or single-pass?** N passes. User's architectural correction. +- **Should --competitors still be an engine flag at all?** Yes, kept for headless / cron contexts with backend keys. Dominant Claude Code path is SKILL.md shortcut → vs-mode fanout. Engine flag stays as compatibility surface. +- **`--competitors-plan` JSON or multi-flag?** JSON. Matches `--plan`. +- **Default count?** 2 peers → 3-way comparison. Unchanged. +- **Saved-file naming?** `{entity-slug}-raw.md` per entity, same as single-entity runs would produce. + +### Deferred to Implementation + +- Exact trace of override-leak path (closure capture vs shared config vs Reddit adapter fallback). Test-first per Unit 2; patch at the right layer. +- Heuristic for single-token-ambiguous Polymarket auto-skip. Start with a short hard-coded list; iterate. +- Whether to include a head-to-head rivalry supplemental pass in vs-mode. Ship N-independent passes first; revisit after dogfood if rivalry content is missing. +- Exact filename convention when the comparison merged output is saved (if saved at all). Not blocking — per-entity files are the primary save artifact. + +## High-Level Technical Design + +> *This illustrates the intended approach and is directional guidance for review, not implementation specification. The implementing agent should treat it as context, not code to reproduce.* + +``` +User invokes: + /last30days "OpenAI vs Anthropic vs xAI" + OR + /last30days OpenAI --competitors (hosting model rewrites to vs form) + OR + /last30days OpenAI --competitors-list "Anthropic,xAI" + OR + /last30days "OpenAI vs Anthropic vs xAI" --competitors-plan '{...per-entity...}' + + ↓ + +scripts/last30days.py main(): + - Detect: topic has " vs " OR --competitors enabled + - If --competitors and no list/plan: emit LAW 7-style stderr with hosting-model instruction + - If --competitors with list or discovery: rewrite topic to vs form, continue + - Parse --competitors-plan JSON, map to entities + + ↓ + +fanout.run_competitor_fanout (shared path): + - For each entity (main + peers): + - entity_config = dict(config) [deep copy to prevent leak] + - kwargs = _subrun_kwargs(entity, plan_entry) [explicit; no main-topic leak] + - If plan_entry missing a field AND backend available: auto_resolve() fill + - pipeline.run(topic=entity, **kwargs, internal_subrun=True) + - Parallel ThreadPoolExecutor + - Collect per-entity Reports + - Attach resolved targeting to each Report.artifacts["resolved"] + + ↓ + +scripts/last30days.py after fanout: + - If --save-dir: save each entity's Report as {entity-slug}-raw.md + Each file includes its own single-row Resolved Entities block + - emit_comparison_output → render_comparison_multi (merged stdout) + Includes full N-row Resolved Entities block +``` + +## Implementation Units + +- [ ] **Unit 1: vs-topic detection routes to fanout (not single-pipeline)** + +**Goal:** A topic containing ` vs ` / ` versus ` triggers `fanout.run_competitor_fanout` with the parsed entities. Each entity runs a full `pipeline.run()`. Replace the current single-pipeline-with-comparison-plan behavior. + +**Requirements:** R1 + +**Dependencies:** None + +**Files:** +- Modify: `scripts/last30days.py` (main() — detect vs-topic, route to fanout) +- Modify: `scripts/lib/planner.py` (remove / bypass the `_should_force_deterministic_plan` special case for vs topics; vs topics no longer go through `plan_query` as a single comparison plan) +- Test: `tests/test_vs_mode_fanout.py` (new) + +**Approach:** +- Parse the incoming topic: if it contains ` vs ` or ` versus ` (case-insensitive), split into entities (reuse `planner._comparison_entities`-style logic or move that utility into main()). +- When vs-entities are detected, route to the same fanout branch `--competitors` uses today. The entity list comes from the topic string; no discovery step needed. +- Each entity runs `pipeline.run()` with its own plan (either from `--competitors-plan[entity]` or from the engine's per-entity fallback path). +- For back-compat, if the user passes both a vs-topic AND `--plan`, honor `--plan` for the main (first) entity and use per-entity defaults for peers unless `--competitors-plan` is also provided. + +**Execution note:** Start with an integration test that runs `"A vs B"` via mock mode and asserts fanout was called with two entities + two pipeline.run calls. + +**Patterns to follow:** +- 3.0.11 fanout wiring in `scripts/last30days.py`'s `--competitors` branch. +- `planner._comparison_entities` for the split logic. + +**Test scenarios:** +- Happy path: topic `"A vs B"` → two pipeline.run calls, two Reports returned, merged render. +- Happy path: topic `"A vs B vs C"` → three pipeline.run calls. +- Happy path: topic `"A versus B"` → matches the same regex, two pipelines. +- Edge case: topic `"OpenAI vs"` (trailing empty entity) → treated as single-entity `"OpenAI"`, not vs mode. +- Edge case: topic contains "vs." (dot, no trailing space) → existing regex tolerates it; verify. +- Edge case: topic `"A vs B"` plus `--plan` → plan applies to first entity only, peers use per-entity defaults. +- Integration: full vs-mode run end-to-end in mock mode; verify rendered output, stderr has one `[Competitors] Comparing: A vs B vs ...` line. + +**Verification:** +- Test assertions pass. +- Mock-mode smoke of `/last30days "OpenAI vs Anthropic"` shows fanout invocation, per-entity Reports, merged comparison output. + +- [ ] **Unit 2: `--competitors-plan` JSON flag + `_subrun_kwargs` helper + override-leak fix** + +**Goal:** New JSON flag threads per-entity targeting into each sub-run's `pipeline.run()`. A `_subrun_kwargs(entity, plan_entry)` helper is the single source of truth for per-entity kwargs, eliminating override-leak. + +**Requirements:** R3, R6 + +**Dependencies:** None (can land alongside or before Unit 1) + +**Files:** +- Modify: `scripts/last30days.py` (argparse + parse + `_competitor_runner` + `_subrun_kwargs` helper) +- Possibly modify: `scripts/lib/fanout.py` (no signature change expected; the competitor_runner contract is unchanged) +- Test: `tests/test_cli_competitors.py` (extend) +- Test: `tests/test_competitors_plan_threading.py` (new) +- Test: `tests/test_competitor_subrun_isolation.py` (new, regression) + +**Approach:** +- Add `--competitors-plan` argparse flag. Accepts inline JSON or file path (mirror `--plan`). +- Validation: top-level dict; each value is a dict; unknown fields log warnings; malformed input exits 2. Case-insensitive entity matching. +- Schema: `{entity_name: {x_handle?, x_related?, subreddits?, github_user?, github_repos?, context?}}`. +- Build `_subrun_kwargs(entity, plan_entry)` — returns an explicit dict with every per-entity flag. No closure-default fallthrough. This is the leak fix. +- `_competitor_runner(entity)`: + 1. Get `plan_entry` from `--competitors-plan` if present. + 2. Build base kwargs with `_subrun_kwargs(entity, plan_entry)`. + 3. Fill missing fields via `resolve.auto_resolve(entity, entity_config)` only if backend is configured (3.0.12 fallback path). + 4. Call `pipeline.run(topic=entity, internal_subrun=True, **kwargs)`. + 5. Attach `resolved` dict to `report.artifacts`. +- Verify no per-entity flag from main() leaks via closure. The helper is the only source of per-entity values. + +**Execution note:** Test-first for the override-leak regression. Use the Kanye 2026-04-22 receipt as the failing test input (main `--subreddits=Kanye,hiphopheads` + `--competitors-list "Drake"` → assert Drake's pipeline.run receives `subreddits=None`). + +**Patterns to follow:** +- `--plan` parsing block in `scripts/last30days.py`. +- 3.0.12's `entity_config = dict(config)` deep-copy pattern. + +**Test scenarios:** +- Happy path: `--competitors-plan '{"Drake":{"x_handle":"Drake","subreddits":["Drizzy"]}}'` → Drake's pipeline.run receives `x_handle="Drake"`, `subreddits=["Drizzy"]`. No auto_resolve call for Drake. +- Happy path: plan covers 2 of 3 entities, backend configured → covered skip auto_resolve; third falls back. +- Happy path: plan file path accepted like `--plan`. +- Happy path: case-insensitive entity match. +- Edge case: unknown fields → warn, ignore. +- Edge case: plan entry for entity not in list → warn, ignore. +- Error path: malformed JSON → exit 2. +- Error path: top-level JSON is list → exit 2. +- Regression (leak): main `--subreddits=A,B` + `--competitors-list "X"` + no plan → X's pipeline.run gets `subreddits=None`. +- Regression (leak): same for `--x-handle`, `--x-related`, `--tiktok-hashtags`, `--tiktok-creators`, `--ig-creators`, `--github-user`, `--github-repo`. +- Regression (leak): main `--x-handle=kanye` + plan `{"Drake":{"x_handle":"Drake"}}` → Drake's sub-run gets `x_handle="Drake"`, NOT `"kanye"`. + +**Verification:** +- All regression tests pass. +- Smoke run (mock mode + plan): stderr shows per-entity `[Competitors] {entity}: x=... subs=...` line; no leak from main topic's flags. + +- [ ] **Unit 3: Per-entity save files** + +**Goal:** When `--save-dir` is set in a vs-mode or `--competitors` run, each entity's sub-run saves its own `{entity-slug}-raw.md` file — same format as a single-entity run would produce. + +**Requirements:** R4, R5 + +**Dependencies:** Unit 1, Unit 2 + +**Files:** +- Modify: `scripts/last30days.py` (`save_output` iteration after fanout) +- Modify: `scripts/lib/render.py` (`render_full` includes single-row Resolved Entities block when that entity's `artifacts["resolved"]` is present) +- Test: `tests/test_save_raw_per_entity.py` (new) + +**Approach:** +- After fanout completes, iterate `report.artifacts["competitor_reports"]` (or equivalent). For each `(entity, entity_report)`: + - Call `save_output(entity_report, emit="md", save_dir=args.save_dir, suffix=args.save_suffix)`. + - Uses entity's `slugify(entity)` for the filename. Same pattern a single-entity run uses. +- Each saved file invokes `render_full` (or the save-variant). `render_full` now checks for `report.artifacts["resolved"]` and prepends a single-row Resolved Entities block. +- Stderr logs one `[last30days] Saved output to ` line per entity. +- Single-entity runs unchanged (no extra files, render_full unchanged for them). + +**Patterns to follow:** +- Existing `save_output` invocation in main() for single-entity runs. +- `slugify(topic)` for filename. +- 3.0.12's `_render_resolved_entities_block` (reused, single-row mode). + +**Test scenarios:** +- Happy path: `/last30days "A vs B vs C" --save-dir=/tmp/x` → `/tmp/x/a-raw.md`, `/tmp/x/b-raw.md`, `/tmp/x/c-raw.md` exist. +- Happy path: `--competitors-list "Drake,Kendrick" --save-dir=/tmp/x` on topic Kanye → three files: `kanye-west-raw.md`, `drake-raw.md`, `kendrick-lamar-raw.md`. +- Happy path: each file includes a single-row Resolved Entities block for its entity. +- Happy path: single-entity run with `--save-dir` → one file, no Resolved block (unchanged). +- Edge case: `--save-suffix=v3` → all N files get the suffix. +- Edge case: one entity sub-run failed → its file is NOT saved; the others are. +- Integration: `ls {save-dir}/*-raw.md` returns N files after a vs-mode run. + +**Verification:** +- Test assertions pass. +- Manual vs-mode smoke saves N files. + +- [ ] **Unit 4: LAW 7-style stderr reframe + footer-nudge suppression** + +**Goal:** The `--competitors`-with-no-backend stderr tells the hosting model to do Step 0.55 per entity and pass `--competitors-plan`. The BRAVE/SERPER footer nudge is suppressed when `--plan` or `--competitors-plan` is present. + +**Requirements:** R7, R8 + +**Dependencies:** Unit 2 (flag must exist) + +**Files:** +- Modify: `scripts/last30days.py` (the `[Competitors] --competitors requires...` stderr block) +- Modify: `scripts/lib/quality_nudge.py` (or wherever footer nudge emits; verify during implementation) +- Test: `tests/test_competitors_no_backend_message.py` (new) +- Test: `tests/test_footer_nudge_suppression.py` (new) + +**Approach:** +- Rewrite stderr in this order: + 1. "If you are the hosting reasoning model (Claude Code, Codex, Hermes, Gemini, or any agent with WebSearch), the recommended path: (a) discover N peers via WebSearch, (b) run Step 0.55 for main + each peer, (c) re-invoke as `/last30days 'topic vs peer1 vs peer2' --competitors-plan '{...}'`. See SKILL.md 'Competitor mode'." + 2. "Headless / cron path: set BRAVE_API_KEY / EXA_API_KEY / SERPER_API_KEY / PARALLEL_API_KEY / OPENROUTER_API_KEY and re-run." + 3. "Minimum escape hatch: `--competitors-list 'A,B,C'` skips discovery but does not pre-resolve peers." +- Suppress footer nudge when `external_plan` OR `competitors_plan` was passed. + +**Test scenarios:** +- Happy path: `--competitors` with no backend, no list, no plan → stderr leads with "If you are the hosting reasoning model" and references `--competitors-plan` before naming API keys. +- Happy path: `--plan` passed → footer nudge does NOT fire. +- Happy path: `--competitors-plan` passed → footer nudge does NOT fire. +- Happy path: `--competitors-list` only (no plan, no backend) → footer nudge still fires (hosting model didn't fully engage). +- Happy path: no `--competitors`, no `--plan` → footer nudge unchanged. + +**Verification:** +- Tests pass. + +- [ ] **Unit 5: Polymarket disambiguation guard** + +**Goal:** `--polymarket-keywords "kw1,kw2"` filters market matches; auto-skip Polymarket on single-token-ambiguous topics without override. + +**Requirements:** R9 + +**Dependencies:** None + +**Files:** +- Modify: `scripts/last30days.py` (argparse) +- Modify: `scripts/lib/polymarket.py` +- Test: `tests/test_polymarket_disambiguation.py` (new) + +**Approach:** +- Add `--polymarket-keywords "kw1,kw2"`. When provided, Polymarket adapter filters market titles to those whose normalized text contains at least one keyword. +- Auto-skip: if topic is one token AND matches a known-ambiguous list (US state names, US city names, common sports/color/animal words) AND no `--polymarket-keywords`, skip Polymarket with stderr note. +- SKILL.md update (small): mention `--polymarket-keywords` in Step 0.55 instructions for ambiguous topics. + +**Test scenarios:** +- Happy path: topic "Warriors", no override → Polymarket skipped; stderr note. +- Happy path: topic "Warriors", `--polymarket-keywords "nba,gsw"` → Polymarket runs, filtered. +- Happy path: topic "OpenAI" → Polymarket runs as before. +- Happy path: topic "Arizona Wildcats" (multi-token) → Polymarket runs as before. +- Edge case: `--polymarket-keywords ""` → treated as empty, no filter. + +**Verification:** +- Warriors smoke → Polymarket footer absent or filtered. + +- [ ] **Unit 6: SKILL.md rewrite — vs mode is the canonical path, `--competitors` is a shortcut** + +**Goal:** SKILL.md documents the unified architecture. vs mode runs N full passes. `--competitors` is a SKILL.md-level shortcut that discovers 2 peers and invokes vs mode with `--competitors-plan`. + +**Requirements:** R1, R2, R10 (surfaces them) + +**Dependencies:** Units 1-4 + +**Files:** +- Modify: `SKILL.md` (§551 "If QUERY_TYPE = COMPARISON" rewrite; Competitor mode subsection rewrite) +- Modify: `README.md` (one-line example) + +**Approach:** +- Rewrite §551 to describe the N-pass architecture: "When the user asks 'X vs Y' (or 'X vs Y vs Z'), run Step 0.55 per entity, then invoke the engine. The engine fans out N full pipelines in parallel. Each entity gets its own single-entity-grade coverage. Wall clock is close to a single run." +- Remove the "ONE research pass with a comparison-optimized plan that replaces the old 3-pass approach" language. +- Add a `--competitors-plan` JSON example. +- Rewrite the Competitor mode subsection: "`--competitors` is a shortcut. The hosting model: (1) runs WebSearch to discover N=2 peers, (2) runs Step 0.55 for main + each peer, (3) rewrites topic to `'main vs peer1 vs peer2'`, (4) invokes engine with `--competitors-plan '{...}'`. Engine flag `--competitors` and `--competitors-list` remain for headless fallback." +- Cross-reference §679 (per-entity Step 0.55 protocol). +- Warning: a thin `## Resolved Entities` block (dashes for any entity) means the hosting model skipped Step 0.55 for that one. + +**Patterns to follow:** +- Existing §679 per-entity Step 0.55 protocol for tone. +- 3.0.12 Competitor mode prose for terseness. + +**Test scenarios:** +- Test expectation: none — documentation. Verification is dogfood. + +**Verification:** +- `/last30days "OpenAI vs Anthropic vs xAI"` in a fresh Claude Code window produces 3 save files with populated Resolved blocks and non-dash per-entity targeting. +- `/last30days OpenAI --competitors` produces same after discovery step. + +- [ ] **Unit 7: Version 3.0.13, CHANGELOG, sync, hot-copy** + +**Goal:** Ship 3.0.13 to all local targets. + +**Requirements:** Closes R1-R10 + +**Dependencies:** Units 1-6 + +**Files:** +- Modify: `.claude-plugin/plugin.json` +- Modify: `CHANGELOG.md` +- Run: `bash scripts/sync.sh` +- Hot-copy: `~/.claude/plugins/cache/last30days-skill/last30days/3.0.13/` + +**Approach:** +- CHANGELOG: group the changes. "Changed: vs mode now runs N full passes in parallel, one per entity — reverting the one-pass optimization to restore per-entity depth. Added: --competitors-plan JSON for per-entity Step 0.55 targeting (applies to vs mode and --competitors). Changed: --competitors is now a SKILL.md shortcut for vs-with-discovery. Added: per-entity *-raw.md save files. Fixed: override-leak from main to peer sub-runs. Changed: LAW 7 stderr framing for hosting-model context. Changed: BRAVE/SERPER footer nudge suppressed when --plan / --competitors-plan present. Added: --polymarket-keywords + auto-skip for ambiguous topics." +- Beta channel first per CLAUDE.md. +- Hot-copy so public `/last30days` picks up 3.0.13. + +**Test scenarios:** +- Test expectation: none — packaging. + +**Verification:** +- `grep version .claude-plugin/plugin.json` → 3.0.13. +- `sync.sh` exits 0. +- Hot-copy contains the new files. + +## System-Wide Impact + +- **Interaction graph:** vs-mode and `--competitors` share one orchestrator (`fanout.run_competitor_fanout`). `_subrun_kwargs` is the single source of per-entity kwargs. Save loop iterates per entity. +- **Error propagation:** Per-entity sub-run failure → logged, dropped, continue (3.0.11 behavior unchanged). `--competitors-plan` JSON parse errors exit 2 (same shape as `--plan`). +- **State lifecycle risks:** `entity_config = dict(config)` deep-copy pattern extends to every per-entity flag (Unit 2 fix). No cross-entity context leak. +- **API surface parity:** `--competitors-plan` is additive. `--competitors`, `--competitors-list`, `--plan` unchanged. `--polymarket-keywords` additive. vs-mode keeps its topic-string surface. +- **Integration coverage:** New vs-mode-fanout integration test. New override-leak regression test. New plan-threading test. New nudge-suppression test. New per-entity-save test. New Polymarket disambiguation test. +- **Unchanged invariants:** `pipeline.run()` signature unchanged. Single-entity render path unchanged. LAW 7 on the default path unchanged (still fires when a single-entity run lacks `--plan`). + +## Risks & Dependencies + +| Risk | Mitigation | +|------|------------| +| vs-mode N-pass latency feels slower for users who remember the one-pass shortcut. | Parallel execution keeps wall-clock ~= max(per-entity-latency), not sum. `--quick` on a vs-topic still applies to each sub-run. CHANGELOG calls out the revert + parallelism. | +| API cost scales linearly with N (per source). | Default count 2 caps it. Hard max 6 on `--competitors`. vs-mode users opted into N entities explicitly. | +| Rivalry content ("A vs B" articles) missed in N-independent passes. | Deferred to separate task (head-to-head supplemental pass). Start shipping and observe whether this is actually a gap. | +| Hosting model skips `--competitors-plan` and uses `--competitors-list` only. | Unit 4 stderr reframe steers explicitly. SKILL.md Unit 6 makes the plan-path canonical. Thin Resolved block in output makes skipped-Step-0.55 visible. | +| Override-leak fix misses a subtle closure path. | Unit 2 is test-first with the Kanye receipt as the failing input. Regression test asserts every per-entity flag is None unless plan provides it. | + +## Documentation / Operational Notes + +- Beta channel first per CLAUDE.md. +- After merge: hot-copy to `~/.claude/plugins/cache/last30days-skill/last30days/3.0.13/`. +- CHANGELOG explicitly frames the vs-mode change as an architectural revert-with-parallelism, not a regression to the old serial N-pass. + +## Sources & References + +- Superseded plan: `docs/plans/2026-04-22-004-fix-competitors-hosting-model-resolve-and-leak-plan.md.superseded` +- Previous plan (3.0.12): `docs/plans/2026-04-22-003-fix-competitors-per-entity-resolution-plan.md` +- Initial plan (3.0.11): `docs/plans/2026-04-22-002-feat-competitors-flag-comparison-fanout-plan.md` +- 2026-04-22 test session receipts (Warriors, Seattle, Arizona Wildcats, Kanye West) +- SKILL.md §551 + §679 — the per-entity Step 0.55 protocol the hosting model uses for both paths +- Related code: `scripts/lib/fanout.py`, `scripts/last30days.py` `_competitor_runner`, `scripts/lib/planner.py` vs-topic special-case, `scripts/lib/render.py` `_render_resolved_entities_block`, `scripts/lib/polymarket.py`, `scripts/lib/quality_nudge.py` +- Related PRs: #308 (3.0.11), #309 (3.0.12) diff --git a/docs/plans/2026-04-22-006-fix-comparison-title-skill-attribution-plan.md b/docs/plans/2026-04-22-006-fix-comparison-title-skill-attribution-plan.md new file mode 100644 index 0000000..e578c44 --- /dev/null +++ b/docs/plans/2026-04-22-006-fix-comparison-title-skill-attribution-plan.md @@ -0,0 +1,87 @@ +--- +title: "fix: comparison title says (/Last30Days) instead of (Last 30 Days)" +type: fix +status: active +date: 2026-04-22 +--- + +# fix: comparison title says (/Last30Days) instead of (Last 30 Days) + +## Overview + +User feedback 2026-04-22 on the 3.0.13 release runs (Kanye vs Drake, Mercer Island, Figma): the comparison title currently reads `# Kanye West vs Drake: What the Community Says (Last 30 Days)`. It should read `# Kanye West vs Drake: What the Community Says (/Last30Days)` — attributing the output to the slash command rather than describing the date range generically. + +Single-line change in SKILL.md, three occurrences. No code change. + +## Requirements Trace + +- R1. Comparison title pattern in SKILL.md changes from `(Last 30 Days)` to `(/Last30Days)` so synthesis outputs read `... What the Community Says (/Last30Days)`. +- R2. Both the rule statement (line 113) and the COMPARISON-exception statement (line 131) and the synthesis template example (line 1208) all use the new suffix. +- R3. Version bumps to 3.0.14, CHANGELOG entry, sync, hot-copy. Public cache picks up the new title pattern. + +## Scope Boundaries + +- No changes to the single-entity output title (no `(/Last30Days)` suffix there — only comparison topics carry it). +- No changes to engine code. Pure SKILL.md content. +- No changes to anything else surfaced in the test runs. + +## Key Technical Decisions + +- **Replace all three occurrences of the suffix string in one pass.** They are identical strings; changing one without the others would cause synthesis-time confusion when the model reaches a different reference. +- **Ship as 3.0.14, not 3.0.13.x.** Patch-level bump matches the small scope and keeps the release log clean. + +## Implementation Units + +- [ ] **Unit 1: Replace `(Last 30 Days)` → `(/Last30Days)` in SKILL.md** + +**Goal:** All three SKILL.md references to the comparison title use the new suffix. + +**Requirements:** R1, R2 + +**Files:** +- Modify: `SKILL.md` + +**Approach:** +- `replace_all` swap of `What the Community Says (Last 30 Days)` → `What the Community Says (/Last30Days)`. Three occurrences, no other strings overlap. + +**Test scenarios:** +- Test expectation: none — pure documentation. Verification by inspection + dogfood run. + +**Verification:** +- `grep -c "What the Community Says (/Last30Days)" SKILL.md` returns 3. +- `grep -c "What the Community Says (Last 30 Days)" SKILL.md` returns 0. + +- [ ] **Unit 2: Version 3.0.14 + CHANGELOG + sync + hot-copy** + +**Goal:** Ship 3.0.14 to all local targets. + +**Requirements:** R3 + +**Dependencies:** Unit 1 + +**Files:** +- Modify: `.claude-plugin/plugin.json` +- Modify: `CHANGELOG.md` +- Run: `bash scripts/sync.sh` +- Hot-copy: `~/.claude/plugins/cache/last30days-skill/last30days/3.0.14/` + +**Approach:** +- CHANGELOG: "Changed: comparison-mode title attribution — `What the Community Says (Last 30 Days)` → `What the Community Says (/Last30Days)`. Surfaces the slash-command identity instead of restating the date range." + +**Test scenarios:** +- Test expectation: none — packaging. + +**Verification:** +- `grep version .claude-plugin/plugin.json` → 3.0.14. +- Hot-copy contains the updated SKILL.md. + +## Risks & Dependencies + +| Risk | Mitigation | +|------|------------| +| Hosting model has the old title pattern memorized from a prior run and re-emits `(Last 30 Days)`. | SKILL.md is read top-to-bottom each invocation. STEP 0 canonical-path self-check (3.0.12) ensures the model loads the new SKILL.md, not the marketplace stale copy. | + +## Sources & References + +- 2026-04-22 dogfood runs (Kanye West vs Drake, Mercer Island --competitors, Figma --competitors) +- Related code: `SKILL.md` lines 113, 131, 1208 diff --git a/scripts/last30days.py b/scripts/last30days.py index 0281ebb..b99245a 100644 --- a/scripts/last30days.py +++ b/scripts/last30days.py @@ -241,9 +241,138 @@ def build_parser() -> argparse.ArgumentParser: dest="competitors_list", help="Comma-separated competitor entities to skip discovery (e.g., 'Anthropic,xAI,Google Gemini'). Implies --competitors.", ) + parser.add_argument( + "--polymarket-keywords", + dest="polymarket_keywords", + help=( + "Comma-separated keywords that Polymarket market titles must match " + "to be included. Use for ambiguous single-token topics like 'Warriors' " + "(nba,gsw,golden-state) to filter out Glasgow Warriors rugby, Honor " + "of Kings Rogue Warriors, etc. When omitted, Polymarket returns all " + "matching markets — so expect cross-entity noise on generic topics." + ), + ) + parser.add_argument( + "--competitors-plan", + dest="competitors_plan", + help=( + "JSON mapping of per-entity Step 0.55 targeting for competitor / vs-mode " + "sub-runs. Schema: {entity_name: {x_handle?, x_related?, subreddits?, " + "github_user?, github_repos?, context?}}. Accepts inline JSON or a file " + "path. Implies --competitors. Preferred over --competitors-list when the " + "hosting model has already resolved per-entity handles and subs." + ), + ) return parser +def parse_competitors_plan(raw: str | None) -> dict[str, dict]: + """Parse a --competitors-plan argument into a {entity_name_lower: plan_entry} dict. + + Accepts inline JSON or a file path (matches --plan). Returns {} on None/empty. + Validation: top-level must be a dict; each value must be a dict. Unknown fields + in entry values log a warning but do not abort. Invalid JSON or non-dict shape + raises SystemExit(2) with a clear stderr message. + """ + if not raw: + return {} + plan_str = raw + if os.path.isfile(plan_str): + try: + plan_str = open(plan_str).read() + except OSError as exc: + sys.stderr.write(f"[CompetitorsPlan] Cannot read plan file: {exc}\n") + raise SystemExit(2) + try: + parsed = json.loads(plan_str) + except json.JSONDecodeError as exc: + sys.stderr.write(f"[CompetitorsPlan] Invalid JSON: {exc}\n") + raise SystemExit(2) + if not isinstance(parsed, dict): + sys.stderr.write( + f"[CompetitorsPlan] Top-level must be a dict of " + f"{{entity: {{targeting}}}}, got {type(parsed).__name__}\n" + ) + raise SystemExit(2) + known_fields = { + "x_handle", "x_related", "subreddits", + "github_user", "github_repos", "context", + } + normalized: dict[str, dict] = {} + for entity, entry in parsed.items(): + if not isinstance(entry, dict): + sys.stderr.write( + f"[CompetitorsPlan] Entry for {entity!r} must be a dict, " + f"got {type(entry).__name__}; skipping.\n" + ) + continue + unknown = set(entry.keys()) - known_fields + if unknown: + sys.stderr.write( + f"[CompetitorsPlan] Unknown fields in {entity!r}: " + f"{sorted(unknown)}; ignoring.\n" + ) + normalized[entity.strip().lower()] = { + k: v for k, v in entry.items() if k in known_fields + } + return normalized + + +def subrun_kwargs_for( + entity: str, + plan_entry: dict, + *, + resolved: dict, +) -> dict: + """Build an explicit per-entity kwargs dict for pipeline.run(). + + Plan values win over auto_resolve values. Returns keys for all per-entity + targeting flags so callers never fall through to closure defaults. + + This helper is the single source of truth for sub-run kwargs — main-topic + flags can only leak if a caller bypasses it. + """ + def _choose(plan_key: str, resolved_key: str | None = None): + if plan_key in plan_entry and plan_entry[plan_key]: + return plan_entry[plan_key] + if resolved_key is not None and resolved.get(resolved_key): + return resolved[resolved_key] + return None + + x_handle = _choose("x_handle", "x_handle") + if isinstance(x_handle, str): + x_handle = x_handle.lstrip("@") or None + + subreddits = _choose("subreddits", "subreddits") + if isinstance(subreddits, list): + subreddits = [s.strip().lstrip("r/") for s in subreddits if s.strip()] or None + + x_related = plan_entry.get("x_related") + if isinstance(x_related, list): + x_related = [h.strip().lstrip("@") for h in x_related if h.strip()] or None + else: + x_related = None + + github_user = _choose("github_user", "github_user") + if isinstance(github_user, str): + github_user = github_user.lstrip("@").lower() or None + + github_repos = _choose("github_repos", "github_repos") + if isinstance(github_repos, list): + github_repos = [r.strip() for r in github_repos if r.strip() and "/" in r.strip()] or None + + context = plan_entry.get("context") or resolved.get("context") or "" + + return { + "x_handle": x_handle, + "x_related": x_related, + "subreddits": subreddits, + "github_user": github_user, + "github_repos": github_repos, + "_context": context, + } + + COMPETITORS_MIN = 1 COMPETITORS_MAX = 6 COMPETITORS_DEFAULT = 2 @@ -322,7 +451,12 @@ def _missing_sources_for_promo(diag: dict[str, object]) -> str | None: return missing[0] -def _show_runtime_ui(report: schema.Report, progress: ui.ProgressDisplay, diag: dict[str, object]) -> None: +def _show_runtime_ui( + report: schema.Report, + progress: ui.ProgressDisplay, + diag: dict[str, object], + suppress_web_promo: bool = False, +) -> None: counts = {source: len(items) for source, items in report.items_by_source.items()} display_sources = list( dict.fromkeys( @@ -339,7 +473,19 @@ def _show_runtime_ui(report: schema.Report, progress: ui.ProgressDisplay, diag: display_sources=display_sources, ) promo = _missing_sources_for_promo(diag) + # The `web` promo nudges users to set BRAVE_API_KEY / SERPER_API_KEY, which + # is wrong advice when a hosting reasoning model (Claude Code, Codex, + # Hermes, Gemini) is driving — those already have WebSearch and can + # pre-resolve Step 0.55 themselves. Suppress the web promo when a hosting + # model signal is present (--plan or --competitors-plan was passed). if promo: + if suppress_web_promo and promo == "web": + return + if suppress_web_promo and promo == "both": + # "both" means reddit + web both missing; still nudge reddit but + # skip the web line. show_promo has a per-source variant. + progress.show_promo("reddit", diag=diag) + return progress.show_promo(promo, diag=diag) @@ -461,6 +607,36 @@ def main() -> int: config["INCLUDE_SOURCES"] = f"{include},perplexity" if include else "perplexity" comp_enabled, comp_count, comp_explicit = resolve_competitors_args(args) + comp_plan = parse_competitors_plan(args.competitors_plan) + + # Polymarket disambiguation: if user passed --polymarket-keywords, + # store on config so the polymarket adapter can filter matches. + if args.polymarket_keywords: + keywords = [ + k.strip().lower() + for k in args.polymarket_keywords.split(",") + if k.strip() + ] + if keywords: + config["_polymarket_keywords"] = keywords + + # vs-mode: if the topic string contains " vs " / " versus " and the + # planner can split it into >=2 entities, route through the same + # N-pass fanout path as --competitors. The first entity becomes the + # main topic; remaining entities become the competitor list. User's + # outer --x-handle / --subreddits apply to the first entity unless + # --competitors-plan covers it. + from lib import planner as _planner + vs_entities = _planner._comparison_entities(topic) + if len(vs_entities) >= 2 and not comp_enabled: + topic = vs_entities[0] + comp_enabled = True + comp_count = len(vs_entities) - 1 + comp_explicit = vs_entities[1:] + sys.stderr.write( + f"[Competitors] vs-mode: routing to N-pass fanout: " + f"{' vs '.join(vs_entities)}\n" + ) def _main_runner() -> schema.Report: r = pipeline.run( @@ -500,15 +676,26 @@ def main() -> int: else: if not resolve_mod._has_backend(config) and not args.mock: sys.stderr.write( - "[Competitors] --competitors requires either a configured web " - "search backend (BRAVE_API_KEY / EXA_API_KEY / SERPER_API_KEY / " - "PARALLEL_API_KEY) or an explicit --competitors-list. If you are " - "the hosting reasoning model (Claude Code, Codex, Hermes, " - "Gemini, or any agent runtime), generate a comma-separated list " - "of 2-6 peer entities for the topic and re-invoke with " - "--competitors-list=\"A,B,C\". You do not need API keys; you ARE " - "the LLM. The web-search discovery path below is the headless / " - "credentialed path only.\n" + "[Competitors] Cannot auto-discover peers without help.\n" + "\n" + "RECOMMENDED PATH (hosting reasoning models — Claude Code, Codex, " + "Hermes, Gemini, any agent with a WebSearch tool): YOU have " + "WebSearch. Use it to run full Step 0.55 per entity, then invoke " + "the engine with a vs-topic plus --competitors-plan:\n" + " 1. WebSearch for '{topic} competitors' or '{topic} alternatives'.\n" + " 2. For each peer, WebSearch for handles/subs/github (Step 0.55).\n" + " 3. Re-invoke: /last30days '{topic} vs {peer1} vs {peer2}' " + "--competitors-plan '{\"Peer1\":{\"x_handle\":\"h1\",\"subreddits\":" + "[\"s1\"],...},\"Peer2\":{...}}'.\n" + "See SKILL.md 'Competitor mode' for the full protocol.\n" + "\n" + "HEADLESS / CRON PATH (no hosting model available): set " + "BRAVE_API_KEY / EXA_API_KEY / SERPER_API_KEY / PARALLEL_API_KEY / " + "OPENROUTER_API_KEY and re-run.\n" + "\n" + "MINIMUM ESCAPE HATCH: pass --competitors-list 'A,B,C' to skip " + "discovery. Without --competitors-plan, peer sub-runs fall back to " + "planner defaults and produce visibly thinner data than the main.\n" ) return 2 discovered = competitors_mod.discover_competitors( @@ -530,6 +717,7 @@ def main() -> int: # leak across sub-runs. Each sub-run writes its own # `_auto_resolve_context` into its local config copy. entity_config = dict(config) + plan_entry = comp_plan.get(entity.strip().lower(), {}) resolved = { "entity": entity, "x_handle": "", @@ -538,7 +726,18 @@ def main() -> int: "github_repos": [], "context": "", } - if not args.mock and resolve_mod._has_backend(entity_config): + # Skip engine-internal auto_resolve when the hosting model + # pre-resolved via --competitors-plan (saves a redundant + # round-trip and makes per-entity Step 0.55 purely + # hosting-model-driven). + plan_covers_fully = bool(plan_entry.get("x_handle")) and bool( + plan_entry.get("subreddits") + ) + if ( + not args.mock + and not plan_covers_fully + and resolve_mod._has_backend(entity_config) + ): try: r = resolve_mod.auto_resolve(entity, entity_config) except Exception as exc: @@ -552,29 +751,41 @@ def main() -> int: resolved["github_user"] = r.get("github_user", "") or "" resolved["github_repos"] = list(r.get("github_repos") or []) resolved["context"] = r.get("context", "") or "" - if resolved["context"]: - entity_config["_auto_resolve_context"] = resolved["context"] - sys.stderr.write( - f"[Competitors] {entity}: " - f"x=@{resolved['x_handle'] or '-'} " - f"subs={len(resolved['subreddits'])} " - f"gh={resolved['github_user'] or '-'}\n" - ) + kwargs = subrun_kwargs_for(entity, plan_entry, resolved=resolved) + # Record effective per-entity targeting for the Resolved block. + resolved_effective = { + "entity": entity, + "x_handle": kwargs["x_handle"] or "", + "subreddits": kwargs["subreddits"] or [], + "github_user": kwargs["github_user"] or "", + "github_repos": kwargs["github_repos"] or [], + "context": kwargs["_context"], + } + if kwargs["_context"]: + entity_config["_auto_resolve_context"] = kwargs["_context"] + sys.stderr.write( + f"[Competitors] {entity}: " + f"x=@{resolved_effective['x_handle'] or '-'} " + f"subs={len(resolved_effective['subreddits'])} " + f"gh={resolved_effective['github_user'] or '-'} " + f"({'plan' if plan_entry else 'auto'})\n" + ) report = pipeline.run( topic=entity, config=entity_config, depth=depth, requested_sources=requested_sources, mock=args.mock, - x_handle=resolved["x_handle"] or None, - subreddits=resolved["subreddits"] or None, - github_user=resolved["github_user"] or None, - github_repos=resolved["github_repos"] or None, + x_handle=kwargs["x_handle"], + x_related=kwargs["x_related"], + subreddits=kwargs["subreddits"], + github_user=kwargs["github_user"], + github_repos=kwargs["github_repos"], web_backend=args.web_backend, lookback_days=args.lookback_days, internal_subrun=True, ) - report.artifacts["resolved"] = resolved + report.artifacts["resolved"] = resolved_effective return report entity_reports = fanout.run_competitor_fanout( @@ -592,14 +803,17 @@ def main() -> int: ) return 1 report = entity_reports[0][1] - report.artifacts["competitor_reports"] = entity_reports else: + entity_reports = None report = _main_runner() except Exception as exc: progress.end_processing() progress.show_error(str(exc)) raise - _show_runtime_ui(report, progress, diag) + _show_runtime_ui( + report, progress, diag, + suppress_web_promo=bool(external_plan or comp_plan), + ) if args.store: counts = persist_report(report) sys.stderr.write( @@ -638,7 +852,6 @@ def main() -> int: ) report.artifacts["pre_research_flags_present"] = pre_research_flags_present - entity_reports = report.artifacts.get("competitor_reports") if hasattr(report, "artifacts") else None if entity_reports: rendered = emit_comparison_output( entity_reports, args.emit, fun_level=fun_level, save_path=footer_save_path, @@ -648,8 +861,18 @@ def main() -> int: report, args.emit, fun_level=fun_level, save_path=footer_save_path, ) if args.save_dir: + # Save the main topic's raw file (single-entity or comparison main). save_path = save_output(report, args.emit, args.save_dir, suffix=args.save_suffix or "") sys.stderr.write(f"[last30days] Saved output to {save_path}\n") + # Competitor / vs-mode: also save a per-entity raw file for each peer. + # Matches historical vs-mode behavior (N passes → N save files). + if entity_reports and len(entity_reports) > 1: + for label, entity_report in entity_reports[1:]: + peer_path = save_output( + entity_report, args.emit, args.save_dir, + suffix=args.save_suffix or "", + ) + sys.stderr.write(f"[last30days] Saved output to {peer_path}\n") sys.stderr.flush() print(rendered) return 0 diff --git a/scripts/lib/pipeline.py b/scripts/lib/pipeline.py index 9fe66fd..41d4a7f 100644 --- a/scripts/lib/pipeline.py +++ b/scripts/lib/pipeline.py @@ -443,7 +443,7 @@ def run( if bundle.items_by_source.get(source): del bundle.errors_by_source[source] - items_by_source = _finalize_items_by_source(bundle.items_by_source, topic=topic) + items_by_source = _finalize_items_by_source(bundle.items_by_source, topic=topic, config=config) candidates = weighted_rrf(bundle.items_by_source_and_query, plan, pool_limit=settings["pool_limit"]) ranked_candidates = rerank.rerank_candidates( topic=topic, @@ -511,6 +511,7 @@ def _normalize_score_dedupe( def _finalize_items_by_source( items_by_source_raw: dict[str, list[schema.SourceItem]], topic: str = "", + config: dict | None = None, ) -> dict[str, list[schema.SourceItem]]: finalized = {} for source, items in items_by_source_raw.items(): @@ -523,6 +524,11 @@ def _finalize_items_by_source( # (e.g., WTI crude oil, Elon tweet counts) before footer emission. if source == "polymarket" and topic: items = polymarket.filter_items_against_topic(topic, items) + # --polymarket-keywords (via config): additional keyword filter + # for ambiguous single-token topics (e.g., "Warriors" → nba,gsw). + keywords = config.get("_polymarket_keywords") if isinstance(config, dict) else None + if keywords: + items = polymarket.filter_items_against_keywords(items, keywords) finalized[source] = items return finalized diff --git a/scripts/lib/polymarket.py b/scripts/lib/polymarket.py index 40ea355..f5b51e5 100644 --- a/scripts/lib/polymarket.py +++ b/scripts/lib/polymarket.py @@ -232,6 +232,39 @@ def filter_items_against_topic(topic: str, items: List[Any]) -> List[Any]: return filtered +def filter_items_against_keywords(items: List[Any], keywords: List[str]) -> List[Any]: + """Keep only items whose title contains at least one keyword (case-insensitive). + + Intended for disambiguating ambiguous single-token topics like 'Warriors' + via --polymarket-keywords (e.g., 'nba,gsw,golden-state') to filter out + Glasgow Warriors rugby, Honor of Kings Rogue Warriors markets that share + the 'Warriors' token but are not the target entity. + """ + if not keywords: + return items + normalized_keywords = [kw.strip().lower() for kw in keywords if kw and kw.strip()] + if not normalized_keywords: + return items + + filtered = [] + for item in items: + title = getattr(item, "title", None) + if title is None and isinstance(item, dict): + title = item.get("title", "") + title = (title or "").lower() + if any(kw in title for kw in normalized_keywords): + filtered.append(item) + + dropped = len(items) - len(filtered) + if dropped: + _log( + f"Keyword filter dropped {dropped} Polymarket items; " + f"kept {len(filtered)} matching {normalized_keywords}" + ) + + return filtered + + def _extract_domain_queries(topic: str, events: List[Dict]) -> List[str]: """Extract domain-indicator search terms from first-pass event tags. diff --git a/scripts/lib/render.py b/scripts/lib/render.py index 13d978b..98c9119 100644 --- a/scripts/lib/render.py +++ b/scripts/lib/render.py @@ -624,6 +624,17 @@ def render_full(report: schema.Report) -> str: lines.extend(f"- {warning}" for warning in report.warnings) lines.append("") + # When this Report is a per-entity sub-run from vs-mode / --competitors, + # include the single-row Resolved Entities block so the saved file is + # self-describing. The artifact is populated by last30days.py's + # _competitor_runner and _main_runner closures. + resolved = report.artifacts.get("resolved") + if isinstance(resolved, dict) and resolved.get("entity"): + single_row = _render_resolved_entities_block([(resolved["entity"], report)]) + if single_row: + lines.extend(single_row) + lines.append("") + # ALL clusters (no limit) lines.append("## Ranked Evidence Clusters") lines.append("") diff --git a/tests/test_competitor_subrun_isolation.py b/tests/test_competitor_subrun_isolation.py new file mode 100644 index 0000000..b6025ff --- /dev/null +++ b/tests/test_competitor_subrun_isolation.py @@ -0,0 +1,196 @@ +# ruff: noqa: E402 +"""Regression tests: main-topic flags must not leak into competitor sub-runs. + +Based on 2026-04-22 Kanye West --competitors receipt where Drake and +Kendrick Lamar sub-runs logged Kanye's resolved subreddit list as their own +targeted search. Per-entity sub-runs must never inherit main-topic targeting +via closure capture, config mutation, or any other path. +""" + +from __future__ import annotations + +import io +import sys +import unittest +from contextlib import redirect_stderr +from pathlib import Path +from unittest import mock + +REPO_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO_ROOT / "scripts")) + + +def _fake_report(topic: str): + class _R: + pass + + r = _R() + r.topic = topic + r.artifacts = {} + return r + + +class SubRunIsolationTests(unittest.TestCase): + """Exercise the _competitor_runner closure pattern from main() directly. + + Builds the same closure shape main() uses, then invokes it with + captured-in-scope main-topic flags to verify they do NOT leak into + sub-run pipeline.run kwargs. + """ + + def _run_closure(self, main_flags, competitors, config=None, mock_flag=False): + """Replicate _competitor_runner closure from last30days.py main(). + + main_flags: dict of {x_handle, x_related, subreddits, tiktok_hashtags, + tiktok_creators, ig_creators, github_user, github_repos} + as they would exist in outer scope after argparse. + competitors: list of entity names to run. + Returns the list of kwargs dicts pipeline.run was called with. + """ + from lib import pipeline, resolve as resolve_mod + + captured: list[dict] = [] + + def fake_run(**kwargs): + captured.append(kwargs) + return _fake_report(kwargs["topic"]) + + # Simulate main scope variables + outer_subreddits = main_flags.get("subreddits") + outer_x_handle = main_flags.get("x_handle") + outer_x_related = main_flags.get("x_related") + outer_tiktok_hashtags = main_flags.get("tiktok_hashtags") + outer_tiktok_creators = main_flags.get("tiktok_creators") + outer_ig_creators = main_flags.get("ig_creators") + outer_github_user = main_flags.get("github_user") + outer_github_repos = main_flags.get("github_repos") + + class _Args: + pass + args = _Args() + args.mock = mock_flag + args.web_backend = "auto" + args.lookback_days = 30 + + cfg = config or {} + + # This mirrors the real _competitor_runner closure structure. + def competitor_runner(entity): + entity_config = dict(cfg) + resolved = { + "entity": entity, + "x_handle": "", + "subreddits": [], + "github_user": "", + "github_repos": [], + "context": "", + } + if not args.mock and resolve_mod._has_backend(entity_config): + try: + r = resolve_mod.auto_resolve(entity, entity_config) + except Exception: + r = {} + resolved["x_handle"] = r.get("x_handle", "") or "" + resolved["subreddits"] = list(r.get("subreddits") or []) + resolved["github_user"] = r.get("github_user", "") or "" + resolved["github_repos"] = list(r.get("github_repos") or []) + resolved["context"] = r.get("context", "") or "" + if resolved["context"]: + entity_config["_auto_resolve_context"] = resolved["context"] + pipeline.run( + topic=entity, + config=entity_config, + depth="default", + requested_sources=None, + mock=args.mock, + x_handle=resolved["x_handle"] or None, + subreddits=resolved["subreddits"] or None, + github_user=resolved["github_user"] or None, + github_repos=resolved["github_repos"] or None, + web_backend=args.web_backend, + lookback_days=args.lookback_days, + internal_subrun=True, + ) + + with mock.patch.object(pipeline, "run", side_effect=fake_run): + for entity in competitors: + competitor_runner(entity) + + return captured + + def test_main_subreddits_do_not_leak_to_peers(self): + """Kanye receipt: main --subreddits=Kanye,hiphopheads leaked to Drake/Kendrick.""" + main_flags = { + "subreddits": ["Kanye", "hiphopheads", "Music", "popheads", "kanyewest"], + "x_handle": "kanyewest", + } + captured = self._run_closure(main_flags, ["Drake", "Kendrick Lamar"]) + self.assertEqual(len(captured), 2) + for kwargs in captured: + self.assertIsNone( + kwargs["subreddits"], + f"Main subreddits leaked into {kwargs['topic']!r}'s sub-run: " + f"{kwargs['subreddits']}", + ) + + def test_main_x_handle_does_not_leak(self): + main_flags = {"x_handle": "kanyewest"} + captured = self._run_closure(main_flags, ["Drake"]) + self.assertIsNone(captured[0]["x_handle"]) + + def test_main_github_does_not_leak(self): + main_flags = { + "github_user": "someuser", + "github_repos": ["someuser/someproject"], + } + captured = self._run_closure(main_flags, ["Drake"]) + self.assertIsNone(captured[0]["github_user"]) + self.assertIsNone(captured[0]["github_repos"]) + + def test_auto_resolve_context_does_not_leak_across_peers(self): + """Per-entity auto_resolve context must not bleed between sub-runs.""" + from lib import resolve as resolve_mod + + def fake_resolve(entity, _cfg): + per_topic = { + "Drake": {"x_handle": "Drake", "subreddits": [], "github_user": "", + "github_repos": [], "context": "Drake ICEMAN rollout", + "category": None, "searches_run": 4}, + "Kendrick Lamar": {"x_handle": "kendricklamar", "subreddits": [], + "github_user": "", "github_repos": [], + "context": "Meet The Grahams revival", + "category": None, "searches_run": 4}, + } + return per_topic.get(entity, {}) + + with mock.patch.object(resolve_mod, "auto_resolve", side_effect=fake_resolve), \ + mock.patch.object(resolve_mod, "_has_backend", return_value=True): + captured = self._run_closure( + main_flags={}, + competitors=["Drake", "Kendrick Lamar"], + config={"BRAVE_API_KEY": "test"}, + ) + + by_topic = {kw["topic"]: kw for kw in captured} + # Each sub-run's config got its own context string. + self.assertEqual( + by_topic["Drake"]["config"].get("_auto_resolve_context"), + "Drake ICEMAN rollout", + ) + self.assertEqual( + by_topic["Kendrick Lamar"]["config"].get("_auto_resolve_context"), + "Meet The Grahams revival", + ) + # Cross-entity check: neither config contains the other's context. + self.assertNotIn( + "Meet The Grahams", + by_topic["Drake"]["config"].get("_auto_resolve_context", ""), + ) + self.assertNotIn( + "ICEMAN", + by_topic["Kendrick Lamar"]["config"].get("_auto_resolve_context", ""), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_competitors_plan_threading.py b/tests/test_competitors_plan_threading.py new file mode 100644 index 0000000..8b8803a --- /dev/null +++ b/tests/test_competitors_plan_threading.py @@ -0,0 +1,180 @@ +# ruff: noqa: E402 +"""Tests for --competitors-plan JSON parsing and per-entity kwargs threading.""" + +from __future__ import annotations + +import io +import json +import sys +import tempfile +import unittest +from contextlib import redirect_stderr +from pathlib import Path +from unittest import mock + +REPO_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO_ROOT / "scripts")) + +import last30days as cli + + +class ParseCompetitorsPlanTests(unittest.TestCase): + def test_none_returns_empty(self): + self.assertEqual(cli.parse_competitors_plan(None), {}) + + def test_empty_string_returns_empty(self): + self.assertEqual(cli.parse_competitors_plan(""), {}) + + def test_inline_json_parsed(self): + raw = '{"Drake": {"x_handle": "Drake", "subreddits": ["Drizzy"]}}' + out = cli.parse_competitors_plan(raw) + self.assertIn("drake", out) + self.assertEqual(out["drake"]["x_handle"], "Drake") + self.assertEqual(out["drake"]["subreddits"], ["Drizzy"]) + + def test_file_path_accepted(self): + with tempfile.NamedTemporaryFile( + mode="w", suffix=".json", delete=False, + ) as f: + json.dump( + {"Anthropic": {"x_handle": "AnthropicAI", "github_user": "anthropics"}}, + f, + ) + path = f.name + try: + out = cli.parse_competitors_plan(path) + self.assertEqual(out["anthropic"]["x_handle"], "AnthropicAI") + self.assertEqual(out["anthropic"]["github_user"], "anthropics") + finally: + Path(path).unlink(missing_ok=True) + + def test_case_insensitive_key_normalization(self): + raw = '{"DRAKE": {"x_handle": "Drake"}}' + out = cli.parse_competitors_plan(raw) + self.assertIn("drake", out) + self.assertNotIn("DRAKE", out) + + def test_unknown_fields_warned_and_ignored(self): + raw = '{"Drake": {"x_handle": "Drake", "bogus_field": 42}}' + err = io.StringIO() + with redirect_stderr(err): + out = cli.parse_competitors_plan(raw) + self.assertIn("drake", out) + self.assertNotIn("bogus_field", out["drake"]) + self.assertIn("Unknown fields", err.getvalue()) + + def test_malformed_json_exits_2(self): + with self.assertRaises(SystemExit) as cm, redirect_stderr(io.StringIO()) as err: + cli.parse_competitors_plan("{not valid json") + self.assertEqual(cm.exception.code, 2) + self.assertIn("Invalid JSON", err.getvalue()) + + def test_top_level_list_rejected(self): + with self.assertRaises(SystemExit) as cm, redirect_stderr(io.StringIO()): + cli.parse_competitors_plan('["Drake", "Kendrick"]') + self.assertEqual(cm.exception.code, 2) + + def test_entry_non_dict_skipped_with_warning(self): + raw = '{"Drake": "not-a-dict", "Kendrick": {"x_handle": "kendricklamar"}}' + err = io.StringIO() + with redirect_stderr(err): + out = cli.parse_competitors_plan(raw) + self.assertNotIn("drake", out) + self.assertIn("kendrick", out) + self.assertIn("must be a dict", err.getvalue()) + + def test_all_six_fields_accepted(self): + raw = json.dumps({ + "OpenAI": { + "x_handle": "OpenAI", + "x_related": ["sama", "gdb"], + "subreddits": ["OpenAI", "MachineLearning"], + "github_user": "openai", + "github_repos": ["openai/gpt-5"], + "context": "GPT-5 launch imminent", + } + }) + out = cli.parse_competitors_plan(raw) + entry = out["openai"] + self.assertEqual(entry["x_handle"], "OpenAI") + self.assertEqual(entry["x_related"], ["sama", "gdb"]) + self.assertEqual(entry["subreddits"], ["OpenAI", "MachineLearning"]) + self.assertEqual(entry["github_user"], "openai") + self.assertEqual(entry["github_repos"], ["openai/gpt-5"]) + self.assertEqual(entry["context"], "GPT-5 launch imminent") + + +class SubrunKwargsForTests(unittest.TestCase): + def test_plan_wins_over_auto_resolve(self): + plan_entry = {"x_handle": "Drake", "subreddits": ["Drizzy"]} + resolved = {"x_handle": "wrong", "subreddits": ["wrong"]} + kwargs = cli.subrun_kwargs_for("Drake", plan_entry, resolved=resolved) + self.assertEqual(kwargs["x_handle"], "Drake") + self.assertEqual(kwargs["subreddits"], ["Drizzy"]) + + def test_auto_resolve_used_when_plan_missing(self): + resolved = { + "x_handle": "Drake", + "subreddits": ["Drizzy", "hiphopheads"], + "github_user": "", + "github_repos": [], + } + kwargs = cli.subrun_kwargs_for("Drake", {}, resolved=resolved) + self.assertEqual(kwargs["x_handle"], "Drake") + self.assertEqual(kwargs["subreddits"], ["Drizzy", "hiphopheads"]) + + def test_both_empty_yields_all_none(self): + kwargs = cli.subrun_kwargs_for("Drake", {}, resolved={}) + self.assertIsNone(kwargs["x_handle"]) + self.assertIsNone(kwargs["subreddits"]) + self.assertIsNone(kwargs["github_user"]) + self.assertIsNone(kwargs["github_repos"]) + self.assertIsNone(kwargs["x_related"]) + self.assertEqual(kwargs["_context"], "") + + def test_x_handle_strips_at_sign(self): + kwargs = cli.subrun_kwargs_for( + "Drake", {"x_handle": "@Drake"}, resolved={}, + ) + self.assertEqual(kwargs["x_handle"], "Drake") + + def test_subreddits_strip_r_prefix(self): + kwargs = cli.subrun_kwargs_for( + "Drake", {"subreddits": ["r/Drizzy", "hiphopheads"]}, resolved={}, + ) + self.assertEqual(kwargs["subreddits"], ["Drizzy", "hiphopheads"]) + + def test_github_repos_filter_non_slash(self): + kwargs = cli.subrun_kwargs_for( + "Drake", + {"github_repos": ["drake/ovo", "not-a-repo"]}, + resolved={}, + ) + self.assertEqual(kwargs["github_repos"], ["drake/ovo"]) + + def test_x_related_list_normalized(self): + kwargs = cli.subrun_kwargs_for( + "Drake", + {"x_related": ["@pnd", "drakefan"]}, + resolved={}, + ) + self.assertEqual(kwargs["x_related"], ["pnd", "drakefan"]) + + def test_github_user_lowercased(self): + kwargs = cli.subrun_kwargs_for( + "OpenAI", {"github_user": "@OpenAI"}, resolved={}, + ) + self.assertEqual(kwargs["github_user"], "openai") + + def test_context_from_plan_or_resolved(self): + plan_entry = {"context": "Plan context"} + resolved = {"context": "Resolved context"} + kwargs = cli.subrun_kwargs_for("X", plan_entry, resolved=resolved) + self.assertEqual(kwargs["_context"], "Plan context") + + kwargs = cli.subrun_kwargs_for("X", {}, resolved=resolved) + self.assertEqual(kwargs["_context"], "Resolved context") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_footer_nudge_suppression.py b/tests/test_footer_nudge_suppression.py new file mode 100644 index 0000000..7631998 --- /dev/null +++ b/tests/test_footer_nudge_suppression.py @@ -0,0 +1,76 @@ +# ruff: noqa: E402 +"""Tests for the BRAVE/SERPER web-promo suppression when hosting-model-driven.""" + +from __future__ import annotations + +import os +import subprocess +import sys +import unittest +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO_ROOT / "scripts")) + + +def _engine() -> Path: + return REPO_ROOT / "scripts" / "last30days.py" + + +class FooterNudgeSuppressionTests(unittest.TestCase): + def _run(self, *argv: str, topic: str) -> subprocess.CompletedProcess: + cmd = [ + sys.executable, + str(_engine()), + topic, + "--mock", + "--emit=md", + *argv, + ] + env = {**os.environ, "LAST30DAYS_SKIP_PREFLIGHT": "1"} + # Strip any grounded-web keys the host might have so the promo path + # triggers deterministically in mock + no-backend. + for key in ("BRAVE_API_KEY", "EXA_API_KEY", "SERPER_API_KEY", + "PARALLEL_API_KEY", "OPENROUTER_API_KEY"): + env.pop(key, None) + return subprocess.run(cmd, capture_output=True, text=True, env=env) + + def test_bare_run_emits_web_promo(self): + result = self._run(topic="OpenAI") + combined = result.stdout + result.stderr + # Mock mode still shows the promo when nothing indicates a hosting + # model is driving. Check both streams since the UI may emit to stderr. + self.assertIn("BRAVE_API_KEY", combined) + + def test_competitors_plan_suppresses_web_promo(self): + result = self._run( + "--competitors-list", "Anthropic", + "--competitors-plan", + '{"Anthropic":{"x_handle":"AnthropicAI","subreddits":["ClaudeAI"]}}', + topic="OpenAI", + ) + combined = result.stdout + result.stderr + self.assertNotIn( + "unlock native grounded web search", + combined, + msg="web promo should be suppressed when --competitors-plan is passed", + ) + + def test_plan_suppresses_web_promo(self): + plan = ( + '{"intent":"concept","freshness_mode":"balanced_recent",' + '"cluster_mode":"none","subqueries":[{"label":"primary",' + '"search_query":"OpenAI","ranking_query":"OpenAI",' + '"sources":["grounding"]}],"source_weights":{"grounding":1.0}}' + ) + result = self._run("--plan", plan, topic="OpenAI") + combined = result.stdout + result.stderr + self.assertNotIn( + "unlock native grounded web search", + combined, + msg="web promo should be suppressed when --plan is passed", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_polymarket_disambiguation.py b/tests/test_polymarket_disambiguation.py new file mode 100644 index 0000000..a74735d --- /dev/null +++ b/tests/test_polymarket_disambiguation.py @@ -0,0 +1,74 @@ +# ruff: noqa: E402 +"""Tests for --polymarket-keywords filter and filter_items_against_keywords.""" + +from __future__ import annotations + +import sys +import unittest +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO_ROOT / "scripts")) + +from lib import polymarket + + +def _item(title: str) -> dict: + return {"title": title} + + +class FilterItemsAgainstKeywordsTests(unittest.TestCase): + def test_no_keywords_returns_all(self): + items = [_item("NBA Finals"), _item("Glasgow Warriors")] + out = polymarket.filter_items_against_keywords(items, []) + self.assertEqual(out, items) + + def test_single_keyword_filters(self): + items = [ + _item("Golden State Warriors win title"), + _item("Glasgow Warriors rugby"), + _item("Honor of Kings: Rogue Warriors"), + ] + out = polymarket.filter_items_against_keywords(items, ["golden"]) + self.assertEqual(len(out), 1) + self.assertIn("Golden State", out[0]["title"]) + + def test_multiple_keywords_any_match(self): + items = [ + _item("NBA Finals: Warriors vs Celtics"), + _item("Glasgow rugby"), + _item("GSW schedule"), + ] + out = polymarket.filter_items_against_keywords(items, ["nba", "gsw"]) + self.assertEqual(len(out), 2) + + def test_case_insensitive_match(self): + items = [_item("Golden State Warriors"), _item("GLASGOW WARRIORS")] + out = polymarket.filter_items_against_keywords(items, ["GOLDEN"]) + self.assertEqual(len(out), 1) + self.assertIn("Golden State", out[0]["title"]) + + def test_empty_keyword_strings_ignored(self): + items = [_item("NBA Finals")] + out = polymarket.filter_items_against_keywords(items, ["", " ", ""]) + # All keywords are empty → treated as no filter + self.assertEqual(out, items) + + def test_sourceitem_like_objects(self): + class _SI: + def __init__(self, t): + self.title = t + + items = [_SI("NBA Finals"), _SI("Glasgow Warriors rugby")] + out = polymarket.filter_items_against_keywords(items, ["nba"]) + self.assertEqual(len(out), 1) + self.assertEqual(out[0].title, "NBA Finals") + + def test_no_match_returns_empty(self): + items = [_item("Glasgow Warriors"), _item("Rogue Warriors")] + out = polymarket.filter_items_against_keywords(items, ["nba", "gsw"]) + self.assertEqual(out, []) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_regression.py b/tests/test_regression.py index c9b4668..b598be0 100644 --- a/tests/test_regression.py +++ b/tests/test_regression.py @@ -29,19 +29,38 @@ class RegressionTests(unittest.TestCase): self.assertIn("clusters", payload) self.assertIn("items_by_source", payload) + def assert_comparison_shape(self, payload: dict) -> None: + """Post-3.0.13: vs-topics produce N full passes, merged output has + comparison=True + entities list + per-entity report wrapper.""" + self.assertTrue(payload.get("comparison")) + self.assertIn("entities", payload) + self.assertIn("reports", payload) + self.assertEqual(len(payload["entities"]), len(payload["reports"])) + # Each report entry wraps a single-topic report + for entry in payload["reports"]: + self.assertIn("entity", entry) + self.assertIn("report", entry) + # Inner report still has the single-topic shape + inner = entry["report"] + self.assertIn("topic", inner) + self.assertIn("query_plan", inner) + self.assertIn("clusters", inner) + def test_openclaw_three_way_comparison_preserves_entities(self): payload = run_mock_json("openclaw vs. nanoclaw vs. ironclaw") - self.assert_common_shape(payload) - plan = payload["query_plan"] - self.assertEqual("comparison", plan["intent"]) - joined_queries = "\n".join(subquery["search_query"] for subquery in plan["subqueries"]).lower() - self.assertIn("openclaw", joined_queries) - self.assertIn("nanoclaw", joined_queries) - self.assertIn("ironclaw", joined_queries) - self.assertNotIn("corsair", joined_queries) - self.assertNotIn("mouse", joined_queries) - for subquery in plan["subqueries"]: - self.assertGreaterEqual(len(subquery["sources"]), 4) + self.assert_comparison_shape(payload) + entities = [e.lower() for e in payload["entities"]] + self.assertIn("openclaw", entities) + self.assertIn("nanoclaw", entities) + self.assertIn("ironclaw", entities) + # No cross-entity keyword pollution in any per-entity report's plan + for entry in payload["reports"]: + plan = entry["report"]["query_plan"] + joined = "\n".join( + sq["search_query"] for sq in plan["subqueries"] + ).lower() + self.assertNotIn("corsair", joined) + self.assertNotIn("mouse", joined) def test_how_to_keeps_web_video_and_discussion_sources(self): payload = run_mock_json("how to deploy on Fly.io") @@ -64,13 +83,24 @@ class RegressionTests(unittest.TestCase): def test_two_way_comparison_preserves_exact_strings(self): payload = run_mock_json("DeepSeek R1 vs GPT-5") - self.assert_common_shape(payload) - plan = payload["query_plan"] - self.assertEqual("comparison", plan["intent"]) - joined_queries = "\n".join(subquery["search_query"] for subquery in plan["subqueries"]).lower() - self.assertIn("deepseek r1", joined_queries) - self.assertIn("gpt-5", joined_queries) - self.assertNotIn("corsair", joined_queries) + self.assert_comparison_shape(payload) + entities_lower = [e.lower() for e in payload["entities"]] + self.assertIn("deepseek r1", entities_lower) + self.assertIn("gpt-5", entities_lower) + # Each per-entity pass has its own entity in its plan + topics_by_entity = { + entry["entity"].lower(): entry["report"]["topic"].lower() + for entry in payload["reports"] + } + self.assertEqual(topics_by_entity["deepseek r1"], "deepseek r1") + self.assertEqual(topics_by_entity["gpt-5"], "gpt-5") + # No cross-entity pollution + for entry in payload["reports"]: + plan = entry["report"]["query_plan"] + joined = "\n".join( + sq["search_query"] for sq in plan["subqueries"] + ).lower() + self.assertNotIn("corsair", joined) if __name__ == "__main__": diff --git a/tests/test_save_raw_per_entity.py b/tests/test_save_raw_per_entity.py new file mode 100644 index 0000000..bc77ec2 --- /dev/null +++ b/tests/test_save_raw_per_entity.py @@ -0,0 +1,84 @@ +# ruff: noqa: E402 +"""Tests for per-entity save files when running vs-mode or --competitors. + +Each entity's sub-run produces its own {entity-slug}-raw.md. Single-entity +runs unchanged. +""" + +from __future__ import annotations + +import os +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO_ROOT / "scripts")) + + +def _engine_path() -> Path: + return REPO_ROOT / "scripts" / "last30days.py" + + +class PerEntitySaveFilesTests(unittest.TestCase): + def _run(self, *argv: str, topic: str) -> tuple[subprocess.CompletedProcess, Path]: + save_dir = Path(tempfile.mkdtemp(prefix="last30days-test-")) + cmd = [ + sys.executable, + str(_engine_path()), + topic, + "--mock", + "--emit=md", + "--save-dir", str(save_dir), + *argv, + ] + env = {**os.environ, "LAST30DAYS_SKIP_PREFLIGHT": "1"} + result = subprocess.run(cmd, capture_output=True, text=True, env=env) + return result, save_dir + + def test_vs_mode_produces_per_entity_files(self): + result, save_dir = self._run(topic="Kanye West vs Drake vs Kendrick Lamar") + self.assertEqual(result.returncode, 0, msg=result.stderr) + files = sorted(save_dir.glob("*-raw.md")) + names = [f.name for f in files] + # Each entity slug should produce a file + self.assertIn("kanye-west-raw.md", names) + self.assertIn("drake-raw.md", names) + self.assertIn("kendrick-lamar-raw.md", names) + + def test_competitors_list_produces_per_entity_files(self): + result, save_dir = self._run( + "--competitors-list", "Anthropic,xAI", + topic="OpenAI", + ) + self.assertEqual(result.returncode, 0, msg=result.stderr) + files = sorted(save_dir.glob("*-raw.md")) + names = [f.name for f in files] + self.assertIn("openai-raw.md", names) + self.assertIn("anthropic-raw.md", names) + self.assertIn("xai-raw.md", names) + + def test_single_entity_run_produces_one_file(self): + result, save_dir = self._run(topic="OpenAI") + self.assertEqual(result.returncode, 0, msg=result.stderr) + files = sorted(save_dir.glob("*-raw.md")) + self.assertEqual(len(files), 1) + self.assertEqual(files[0].name, "openai-raw.md") + + def test_per_entity_file_has_resolved_block(self): + result, save_dir = self._run( + "--competitors-list", "Anthropic", + topic="OpenAI", + ) + self.assertEqual(result.returncode, 0, msg=result.stderr) + anthropic_file = save_dir / "anthropic-raw.md" + self.assertTrue(anthropic_file.exists()) + content = anthropic_file.read_text() + self.assertIn("## Resolved Entities", content) + self.assertIn("**Anthropic**", content) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_vs_mode_fanout.py b/tests/test_vs_mode_fanout.py new file mode 100644 index 0000000..b0dd873 --- /dev/null +++ b/tests/test_vs_mode_fanout.py @@ -0,0 +1,63 @@ +# ruff: noqa: E402 +"""Tests for vs-mode routing into the competitor fanout. + +A topic containing " vs " / " versus " triggers N-pass fanout (not the +old single-pipeline comparison plan). Each entity gets its own full +pipeline.run() with its own Step 0.55 targeting. +""" + +from __future__ import annotations + +import io +import sys +import unittest +from contextlib import redirect_stderr +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO_ROOT / "scripts")) + +from lib import planner + + +class VsModeEntityDetectionTests(unittest.TestCase): + """The planner's _comparison_entities helper is the detector we use.""" + + def test_two_entity_vs(self): + self.assertEqual( + planner._comparison_entities("OpenAI vs Anthropic"), + ["OpenAI", "Anthropic"], + ) + + def test_three_entity_vs(self): + self.assertEqual( + planner._comparison_entities("Kanye West vs Drake vs Kendrick Lamar"), + ["Kanye West", "Drake", "Kendrick Lamar"], + ) + + def test_versus_alt_spelling(self): + result = planner._comparison_entities("A versus B") + self.assertEqual(result, ["A", "B"]) + + def test_dotted_vs(self): + result = planner._comparison_entities("A vs. B") + self.assertEqual(result, ["A", "B"]) + + def test_no_vs_returns_empty(self): + self.assertEqual(planner._comparison_entities("OpenAI"), []) + + def test_trailing_vs_returns_empty_or_single(self): + # "OpenAI vs" with nothing after — should not trigger vs-mode + result = planner._comparison_entities("OpenAI vs") + # _comparison_entities caps at _max_subqueries("comparison") and + # requires >=2 parts. Single "OpenAI" with empty after vs -> [] + self.assertLess(len(result), 2) + + def test_dedup_identical_entities(self): + # Defense against silly input — two "Drake"s should collapse. + result = planner._comparison_entities("Drake vs Drake") + self.assertEqual(result, ["Drake"]) + + +if __name__ == "__main__": + unittest.main()