agentleFS
HefrockHefrock/agent-skills/skills/broadcast/SKILL.md

broadcast

Skill0 starsChanged 4 months agoMITView on GitHub

Produces a daily healthcare AI news audio briefing — ingests from eleven registered sources (PubMed, arXiv, medRxiv, FDA guidance, regulations.gov, FDA MAUDE device adverse events, ONC/ASTP, CMS, and industry RSS feeds), dedupes and ranks stories, pins every claim to its source via a real evidence-pinning MCP server, generates a spoken script, rewrites each story with AI narration (grounded, with an automatic fallback to plain text if the narration can't be verified against its source), runs a QA gate, synthesizes audio via Gemini TTS, and produces GitHub-Pages-ready distribution artifacts (podcast RSS feed + Obsidian vault note). Use this skill when the user wants to run, generate, produce, or publish a healthcare AI briefing episode or podcast; asks about the status of a broadcast episode or run; wants to debug a failed or partial episode (QA failures, narration failures, TTS rate limits); wants to add/remove/tune an ingest source; or asks how the broadcast pipeline works. Requires GEMINI_API_KEY and a locally-built evidence-pinning-mcp server (see Prerequisites).

  • Reads credentials
  • Installs packages

The file

We split this skill into the rules it gives an agent

7 rules. About 0 are common, 1 is distinctive, and 6 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈1

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 6

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Distinctive:

    Read this whole file before running anything live — several of the operational risks below were only discovered by actually running the …

    line 10
  2. Very specific:

    Every source lives in config/sources.json, validated at load time by source_registry.validate_registry() — a malformed entry raises a …

    line 178
  3. Very specific:

    Removing a source: delete its entry from "sources" — nothing else references sources by a fixed list, ingest_all() just iterates whatever's …

    line 182
  4. Very specific:

    Every module in scripts/ has a matching test_*.py, all stdlib unittest, no network calls (network-touching functions like synthesize_text() …

    line 188
  5. Very specific:

    Always report qa_passed, episode_produced, and the narration/synth failure buckets together when summarizing a run — a summary that omits …

    line 231
  6. Very specific:

    Never say an episode was "published" unless distribute.py has run *and* its --publish-dir output has actually been deployed somewhere …

    line 232
  7. Very specific:

    If synth_failed shows HTTP 429, say so plainly and recommend waiting — don't immediately re-run the episode (see Known operational risks).

    line 234

≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

Broadcast — Healthcare AI News Briefing

An automated pipeline that turns the day's healthcare AI news into one spoken-word audio episode, end to end: ingest → dedup/rank → evidence-pinning → script generation → AI narration → QA gate → audio synthesis → distribution artifacts. Every stage is a standalone, independently-tested Python module in scripts/; orchestrate.py wires them into one real run.

Read this whole file before running anything live — several of the operational risks below were only discovered by actually running the pipeline against real APIs, and repeating those mistakes wastes real (rate-limited, sometimes shared) API quota.

Prerequisites

  • Python 3.12 (stdlib only — no pip install needed for any broadcast script).
  • Node 22, and the evidence-pinning-mcp server built once (orchestrate.py spawns it as a subprocess automatically — it does not need to already be running, just built):
    cd mcp/evidence-pinning && npm ci && npm run build
  • GEMINI_API_KEY set in the environment. Used for embeddings (dedup/ranking), AI narration, and text-to-speech — all three stages fail without it.
  • REGULATIONS_GOV_API_KEY (optional). Without it, regulations_gov ingest uses the shared DEMO_KEY (10 req/hr, no registration) — that quota is shared with every other DEMO_KEY caller globally, not scoped to this pipeline, so it can be exhausted by unrelated traffic regardless of how often this pipeline actually runs. A free key from api.data.gov raises that to 1000 req/hr, scoped to just this pipeline. Get one and set this env var whenever regulations_gov — a regulatory-category, highest-authority-floor source — matters enough that you don't want its reliability depending on an unrelated party's request volume.

Running one episode

python ${CLAUDE_PLUGIN_ROOT}/scripts/orchestrate.py --data-dir ~/.broadcast-data \
  [--date YYYY-MM-DD]                    # default: today
  [--max-results-per-source N]           # default: 10 — keep low for a quick/cheap test run
  [--synth-delay-seconds N]              # default: 6.0 — pacing between TTS calls, see risks below
  [--no-narration]                       # skip AI narration, ship plain mechanical text
  [--narration-success-threshold N]      # default: 0.7 — episode-level narration fallback threshold
  [--no-audio-normalize]                 # skip per-segment peak normalization in the assembled episode
  [--inter-segment-silence-ms N]         # default: 400.0 — silence gap between segments (0 to disable)
  [--dry-run]                            # ingest only, print a Gemini call estimate, spend zero quota — see below
  [--no-audio-cache]                     # disable the per-segment synthesized-audio cache — see below
  [--top-three-count N]                  # default: 3 — shrink to reduce a real episode's TTS call count
  [--quick-hits-count N]                 # default: 7 — see --top-three-count
  [--host-style single|dialogue]         # default: single — see below
  [--text-only]                          # archive only: no narration, no TTS (embeddings are the only Gemini calls) — see below

--text-only is the daily run in the daily-text / weekly-audio plan (references/weekly-show-design.md). It still ingests, ranks, pins evidence, builds the script and runs the QA gate, but skips narration and audio synthesis, so the only Gemini calls are one embedding per ingested item. Exit code is 0 when qa_passed is true (no audio is expected). --dry-run --text-only reports 0 narration and 0 TTS calls.

The text archive (archive.py) is written on every real run, with or without --text-only, to <data-dir>/archive/<date>.json and <date>.md. One entry per selected story that has a pinned claim: the source's own headline and summary (verbatim, never narrated text, which is why it is not built from script.json), link, DOI/PMID when there is one, category, claim_id, canonical_id and relevance_score. Entries are self-contained so a weekly pack can be built from the archive files alone. A selected story with no pinned claim is left out.

--host-style dialogue adds a second voice, Host B, reacting to each story alongside Host A's existing grounded narration — closer to a two-host conversational feel (à la NotebookLM's "Deep Dive") than today's single-narrator default. The key design choice: Host B's words are never Gemini-generated. Every reactive turn is picked from a small, fixed, hand-authored pool (narrate.REACTIVE_TEMPLATES) — zero network calls, zero hallucination surface — so this mode adds no new claim that needs grounding; it's a presentation layer over the exact same single grounded narration --host-style single already produces, not a second source of prose to verify. qa_gate.py's reactive_turns_within_pool check mechanically confirms every reactive turn actually came from that pool, not trusted by its own label. Cost impact is small — a few extra spoken seconds per story via audio_synth.synthesize_dialogue()'s two-voice TTS call, not a new class of Gemini call. A story that fails narration's grounding check (or when --host-style single) never gets a second voice at all — always exactly today's single mechanical/narrated text, never a partial dialogue.

Use ~/.broadcast-data as the default --data-dir unless the user asks for somewhere else. It holds persistent state (dedup_store.json, evidence_store/, audio_cache/) and every run's output (episodes/<date>/{report.json,script.json,episode.wav}) — deduplication and evidence provenance only accumulate meaningfully if the same directory is reused run over run, and nothing else in this repo establishes a canonical location. Don't invent a different path per session; that silently defeats the whole story-continuity design. orchestrate.py creates the directory itself if it doesn't exist yet — no setup needed beforehand.

The per-segment audio cache (<data-dir>/audio_cache/, enabled by default) exists because a real run has seen 11 of 14 segments fail to a Gemini TTS rate limit — twice. Each segment's synthesized audio is cached by a hash of its own exact text, so a retry after waiting for quota to recover only re-synthesizes segments that actually failed last time, not the whole episode from scratch — and a truly fixed segment (like the disclosure text, identical every day) only ever needs synthesizing once, ever, shared across every future episode. It's content-addressed by text only, not text+voice+model — if the pinned voice or TTS model ever changes, clear <data-dir>/audio_cache/ manually first, or a retried episode could end up with an inconsistent voice across segments. Not auto-pruned — same status episodes/<date>/ was in before prune_episodes.py existed.

There's also a manually-triggered GitHub Actions workflow, .github/workflows/broadcast-live-smoke-test.yml (workflow_dispatch only, never on push/PR — it makes real external API calls). It has two jobs: a smoke test of the ingest adapters + embeddings, and a full real episode run via orchestrate.py. Triggering it always runs both jobs — there is no way to trigger just one.

There is a third, text-only workflow, .github/workflows/broadcast-live-text-only.yml (workflow_dispatch only), for checking the pipeline against the real network without touching TTS or narration. It runs a --dry-run (live ingest, no Gemini calls), then --text-only (embeddings are the only Gemini calls) if the GEMINI_API_KEY repository secret exists, and optionally checks the evidence server's Crossref classifier against a known-retracted paper. Results, including call_counts, are written to the run summary and an artifact. It cannot reach TTS, so prefer it over the two workflows above for reconnaissance.

Prefer running orchestrate.py directly in-session over triggering that workflow, whenever the session can (i.e. it has GEMINI_API_KEY and can reach the network directly): direct invocation lets you run exactly one thing — e.g. just live_smoke_test.py to check ingest health, or one orchestrate.py call with a low --max-results-per-source — while workflow_dispatch always fires both jobs together, including the TTS-quota-heavy full episode run, whether you wanted that or not. That mismatch is exactly how this project has burned Gemini TTS quota before: a reconnaissance trigger meant to check one small thing also silently re-ran a full episode in the background. Reach for workflow_dispatch only when the session itself can't execute Python/reach the network, or when you specifically need to confirm behavior in the CI environment rather than locally.

Estimating cost before a real run

--dry-run runs real ingestion (free — no fetch_fn call ever touches Gemini) and then stops, printing an estimate of how many Gemini calls a real run with this exact config would make, instead of spending any of that quota to find out. GEMINI_API_KEY does not need to be set to use --dry-run — this is the one CLI mode that doesn't require it, precisely so it can answer "is this worth it" before you've committed to getting a key at all:

{"run_date": "2026-09-03", "dry_run": true, "ingest_failed": [...], "items_ingested": 23, "embed_calls_estimate": 23, "narration_calls_estimate_max": 10, "synth_calls_estimate_max": 14}

embed_calls_estimate is exact — every ingested item gets exactly one embedding call. narration_calls_estimate_max/synth_calls_estimate_max are genuine upper bounds, not predictions: computing the real, smaller number requires ranking, which itself needs embeddings (the one cost --dry-run exists to avoid paying for) — so these are capped at this pipeline's real, fixed selection ceiling (rank.DEFAULT_TOP_THREE_COUNT + rank.DEFAULT_QUICK_HITS_COUNT story segments, plus intro/disclosure/quick_hits_transition/outro), which same-day/rolling-window dedup can only ever shrink, never exceed. No evidence-pinning-mcp server is spawned and nothing is persisted to --data-dir in this mode.

Run this before a real episode whenever you're unsure how expensive it would be, or whenever a source's feed_url has recently changed (an RSS feed's real item count is uncapped by --max-results-per-source — see "Adding, removing, or tuning an ingest source" below — so its true embedding-call cost can only be known by actually checking, not by reading config).

Scheduling

.github/workflows/broadcast-scheduled.yml runs orchestrate.py on a cron cadence (daily at 12:00 UTC by default — a placeholder, change the cron expression to whatever cadence gets decided). It's disabled by default on purpose — a schedule: trigger has no native "off" switch in GitHub Actions, so the job itself is gated on a repository variable:

Settings -> Secrets and variables -> Actions -> Variables -> New repository variable:
BROADCAST_SCHEDULE_ENABLED = true

Until that variable is set, every scheduled firing shows as skipped in the Actions UI (visibly, not silently absent) and spends zero Gemini quota. Run --dry-run (see above) with a realistic --max-results-per-source first to know the real per-run cost before setting this variable — that's the whole reason --dry-run exists.

workflow_dispatch (manual trigger) always runs regardless of the variable — a human manually triggering this is already an explicit decision; the gate exists specifically to keep the unattended cron trigger inert until opted into.

--data-dir persistence in this workflow is best-effort, not durable. GitHub Actions runners are ephemeral, so dedup_store.json/evidence_store continuity across scheduled runs depends on actions/cache (a rolling per-run-id cache key with a prefix restore, since a cache entry can't be overwritten once created) — GitHub evicts unused cache entries after roughly 7 days and caps total repo cache size at 10GB. A real durable --data-dir most likely belongs in whatever repo ends up hosting distribution (see "No real hosting" below) once that exists.

This workflow also does not upload or retain <data-dir>/archive/: only episodes/** is uploaded as an artifact, so the text archive survives only as long as the best-effort --data-dir cache does. It does not pass --text-only either, so it still spends narration and TTS quota.

This workflow does not run distribute.py — publishing needs a real --base-url a hosting target actually serves, which doesn't exist yet. It only produces the episode; wiring in real publishing is a follow-up once hosting is decided.

Reading the result — what "success" actually looks like

report.json (also printed to stdout) is the thing to read, not just the process exit code (which is 1 whenever episode_produced is false, even though everything upstream may have worked correctly):

  • qa_passed — structural + grounding checks on the script itself (intro/outro present, every story traces to a real pinned claim, no leaked excluded stories). Nothing to do with audio.
  • episode_produced — true only if every segment's audio synthesized. One segment failing withholds the whole episode's audio (deliberate — a partial episode isn't assembled with a silent gap). Check synth_failed for which segments and why.
  • narration_attempted / narration_succeeded / narration_success_rate / narration_episode_level_fallback / narration_failures — the AI narration layer's own results. A narration failure is not a pipeline failure — it's a best-effort enhancement with an automatic two-tier fallback (per-segment, then whole-episode) to the original mechanical text, by design. A low success rate or episode_level_fallback: true means that day's episode shipped with plainer prose, not that anything is broken. These fields are all null when --no-narration was passed (distinguishable from a real 0/0 result).
  • call_counts — logical Gemini calls this run made: embed (one per ingested item), narrate, synth, and synth_cache_hits (segments served from audio_cache/ instead). These are calls the pipeline made, not HTTP attempts: a 429 retry inside gemini_retry is not counted, so treat them as a lower bound on quota actually used. They exist because the key's real limits were never measured; compare them across runs. text_only and archive_entry_count sit alongside.
  • ingest_failed — per-source ingest failures. A source documented in config/sources.json as feed_url_verified: false failing is expected every run (see below), not a bug to chase — check the registry entry first before investigating.
  • source_utilization — per-source breakdown of what happened to that run's candidates: candidates (survived same-day-dedup and got scored), selected_top_three / selected_quick_hits / selected_total, not_selected (considered, lost on relevance score), dropped_duplicates (same-day duplicate, never independently considered), and selection_rate (selected_total / candidates, null when candidates is 0). Pure observability, not a QA gate — nothing here fails a run, and a source with zero candidates today might just mean nothing newsworthy happened, not that it's being starved. A single run's numbers are noisy on their own; the useful read is comparing this field across several days' episodes/<date>/report.json files to spot a source that's never winning a slot, not any one day's snapshot. Sources with zero candidates this run don't appear in this dict at all, rather than a fabricated zero-row.

That rolling, multi-day view is source_health_report.py:

python ${CLAUDE_PLUGIN_ROOT}/scripts/source_health_report.py --data-dir ~/.broadcast-data [--days N]  # default: 30

Sums source_utilization across every episodes/<date>/report.json in the window, prints a table sorted by pooled selection_rate (most-starved first), and separately flags any registered source with zero candidates anywhere in the whole window — the clearest "worth asking a human about" signal it can produce. Same discipline as the field it aggregates: nothing here is a QA gate, and it can't tell a genuinely-starved source apart from one that's just legitimately quiet that month — it's a diagnostic to prompt a question, not a verdict. Read-only, standalone, never called automatically.

qa_checks has the same rolling-view gap source_utilization had before source_health_report.py — one run's pass/fail is a snapshot, not a trend. qa_gate_history.py closes it differently: instead of reimplementing pass-rate/regression logic a third time, it flattens qa_checks across a window into skills/agent-eval/'s own JSONL schema (one row per episode/check pair, category = check name) and hands off to that skill's already-tested score_eval.py for the actual aggregation, regressions, and CI gate:

python ${CLAUDE_PLUGIN_ROOT}/scripts/qa_gate_history.py --data-dir ~/.broadcast-data --out qa_results.jsonl [--days N]
python skills/agent-eval/scripts/score_eval.py qa_results.jsonl --fail-under 0.9
python skills/agent-eval/scripts/score_eval.py qa_results.jsonl --baseline last_weeks_qa_results.jsonl --fail-on-regression

A documented file-format handoff, not a cross-skill import — qa_gate_history.py has no dependency on agent-eval being present to run; the second command is only useful when it is.

A real, unedited (trimmed for length) example from an actual live run, captured before healthcare_it_news was removed from the registry (permanently WAF-blocked, no viable RSS alternative found — see "Adding, removing, or tuning an ingest source" below) and before source_utilization existed — qa_passed: true and narration mostly succeeded, but episode_produced is false purely because of a TTS rate limit, not a script problem:

{
  "run_date": "2026-09-02",
  "ingest_failed": [{"source_key": "healthcare_it_news", "error": "HTTPError: HTTP Error 403: Forbidden"}],
  "top_three_count": 3,
  "quick_hits_count": 7,
  "pinned_count": 10,
  "segment_count": 14,
  "narration_attempted": 10,
  "narration_succeeded": 9,
  "narration_success_rate": 0.9,
  "narration_episode_level_fallback": false,
  "narration_failures": [
    {"canonical_id": "pmid:42644472", "reasons": ["narration length ratio 0.19 outside [0.4, 2.0]"]}
  ],
  "qa_passed": true,
  "qa_checks": [
    {"check": "has_intro", "passed": true, "detail": ""},
    {"check": "story_segments_grounded", "passed": true, "detail": ""}
  ],
  "synth_failed": [
    {"segment_type": "top_three_item", "canonical_id": "pmid:42644472", "error": "HTTPError: HTTP Error 429: Too Many Requests"}
  ],
  "episode_produced": false
}

Debugging a failed or partial episode

Work through report.json in this order — nearly every failure traces to one of these, not a new regression:

  1. qa_passed: false — look at qa_checks for entries with passed: false; each one's detail names the exact structural problem (a missing intro/outro, a story segment missing claim_id, etc.). This should essentially never happen from an unmodified orchestrate.py run — script_gen.py already enforces these invariants when it builds the script, and qa_gate.py only re-checks them as defense in depth. A failure here points to a real upstream bug, not something to route around or re-run past.
  2. episode_produced: false but qa_passed: true — the script was fine; audio synthesis failed for at least one segment. Check synth_failed: if every entry reads HTTP Error 429: Too Many Requests, that's the known Gemini TTS rate limit (see Known operational risks below) — wait for quota to recover before doing anything else, don't immediately re-run. Any other error string is a real, new failure worth investigating on its own terms.
  3. ingest_failed non-empty — a source documented in config/sources.json as feed_url_verified: false failing is expected every run; check the registry entry's feed_url_verified_note first. Any other source failing is new: check the error string — a previously-working source returning a different error than before usually means the feed URL or endpoint changed upstream, not a bug in this pipeline.
  4. Low narration_success_rate or narration_episode_level_fallback: true — not a failure at all. This is the narration layer's own designed circuit breaker, working as intended; narration_failures lists which stories fell back and the specific grounding-check reason (an ungrounded span, a dropped hedge, an out-of-range length ratio). The episode still ships — just with plainer prose for that story or that day.

Weekly show (designed, partly built)

A 10-12 minute two-host weekly show built from the week's archive entries is designed in references/weekly-show-design.md (decisions, the review that corrected the first draft, what is still unverified). What exists so far: the archive writer and --text-only above, and weekly_grounding.py, the per-turn grounding contract. A script is a list of turns; a claim turn must cite one claim_id from the week pack, quote a verbatim span from that story, and keep the story's hedges; reactive and link turns must come from fixed hand-written sets, so the only way a fact enters is through a claim turn. Each claim turn is checked against its one cited story: a 3+ word verbatim span, every number and every non-sentence-initial capitalized name present in that story, hedges kept, negation neither added nor dropped. That bounds where facts can come from; it does not prove wording is faithful (a reversed comparison using the story's own words, spelled-out numbers and lowercase entities all pass), so a passing script is not a verified one. The week-pack builder, the weekly generator and chunked two-voice synthesis are not built.

Publishing an episode

python ${CLAUDE_PLUGIN_ROOT}/scripts/distribute.py --data-dir ~/.broadcast-data --date YYYY-MM-DD \
  --publish-dir <dir> --base-url <public-url> --feed-link <url> \
  [--feed-title "..."] [--feed-description "..."] [--feed-author "..."] \
  [--itunes-category "..."]        # default: Technology
  [--itunes-explicit true|false]   # default: false
  [--itunes-type episodic|serial]  # default: episodic
  [--itunes-author "..."]          # overrides --feed-author for itunes:author specifically
  [--itunes-subtitle "..."]
  [--itunes-image-url <url>]       # square, 1400-3000px, JPEG/PNG, no transparency — see below
  [--itunes-owner-name "..."] [--itunes-owner-email "..."]  # <itunes:owner> needs BOTH to appear

This reads the episode orchestrate.py already produced and writes GitHub-Pages-ready files to --publish-dir: the audio file, an RSS feed.xml (RSS 2.0 plus the itunes: namespace — category/explicit/type/author/duration/etc.), and a matching Obsidian vault-note markdown file. It does not push, host, or deploy anything — that's a deliberate, separate, human-driven step. The RSS feed's URLs are only real once --publish-dir's contents are actually deployed to --base-url.

--itunes-image-url (cover art) is the one thing this can't produce itself — this pipeline has no way to generate real artwork, and Apple Podcasts won't list a show without one (square, 1400–3000px, JPEG/PNG, no transparency). Every other itunes: tag works and is emitted correctly with sensible defaults even with no image set; add --itunes-image-url once real art exists, no other change needed.

The vault note is markdown output only, not a vault write — landing it in a real Obsidian vault requires a live session with the wiki-operator skill's /source command and a connected obsidian-vault MCP server. distribute.py deliberately never writes to a vault directly.

Adding, removing, or tuning an ingest source

Every source lives in config/sources.json, validated at load time by source_registry.validate_registry() — a malformed entry raises a specific RegistryValidationError there rather than surfacing as a confusing KeyError three stages downstream.

  • Adding an RSS-backed source (no code changes needed): add an entry with "key", "name", "category" (an existing category, or a new one under "categories" — see "tuning" below), and "feed_url". Any source with a feed_url field is automatically dispatched by orchestrate.py's _fetch_for_source() to ingest.fetch_rss(), the same generic parser already handling four different feeds' real-world quirks (non-RFC-822 pubDate formats, a corrupted <link> field, etc. — see ingest.py's _parse_rss_pubdate() if a new feed's dates come back wrong). Leave "feed_url_verified": false until you've actually confirmed it live with live_smoke_test.py (see below) — every currently-verified source in this file was confirmed that way, never assumed correct from the URL alone. Don't trust a guessed URL, even from a search result: healthcare_it_news (Healthcare IT News) was registered this way, found genuinely, permanently WAF/Cloudflare-blocked (HTTP 403 on the real URL and three guessed variants, even with a full browser-like header set), and was removed entirely rather than left as a documented-broken source indefinitely. Its replacement, hit_consultant, is registered with "feed_url_verified": true: its real feed response (supplied by hand, 2026-09-03) parsed cleanly through ingest.parse_rss_xml(). That confirms the content is parsed correctly but not that fetch_rss()'s own live HTTP call succeeds from this pipeline's runtime, so a real live_smoke_test.py run is still the way to close that last gap (see the source's feed_url_verified_note in config/sources.json).
  • Adding a query-based or fixed-endpoint source (like pubmed/arxiv/regulations_gov/fda_maude, or medrxiv/fda_guidance) needs real code, not just config: a new fetch_*() function in ingest.py (see those existing functions for the two patterns) and a new dispatch branch in orchestrate.py's _fetch_for_source() keyed on the source's "key". Without both, that source raises ValueError at ingest time. fda_maude is a good example of the discipline this section otherwise only describes for RSS sources: it's registered but its query_note says plainly it's NOT yet live-verified — documented field names aren't the same as a confirmed real response, the same lesson healthcare_it_news taught the hard way.
  • Removing a source: delete its entry from "sources" — nothing else references sources by a fixed list, ingest_all() just iterates whatever's currently in the registry.
  • Tuning relevance scoring: authority_floor (0–1) and half_life_days (>0) are set per-category, not per-source (see source_registry.py's docstring for what they control — a higher floor and longer half-life age a story more slowly). Every category and query in config/sources.json already carries a *_note field marking it as "a working default, not a validated measurement" — change these against real episode output, not intuition, and update the note to record why.
  • After any change, run python ${CLAUDE_PLUGIN_ROOT}/scripts/live_smoke_test.py — it's cheap (real ingest + embedding calls, no TTS or narration spend) and will confirm the new or changed source actually returns real items before it's trusted in a full, expensive episode run.

Verifying a code change to this pipeline

Every module in scripts/ has a matching test_*.py, all stdlib unittest, no network calls (network-touching functions like synthesize_text() or generate_narration() are injectable and faked in tests — see any test_orchestrate.py RunEpisodeWiring test for the pattern). Run the full suite before trusting any change:

for f in ${CLAUDE_PLUGIN_ROOT}/scripts/test_*.py; do python3 "$f"; done

test_evidence.py, test_evidence_pinning_client.py, and test_qa_gate.py will report their evidence-pinning-mcp-server-dependent tests as skipped, not failed, if that server isn't built yet — a suite full of OK (skipped=N) can look deceptively clean. Build it first to actually exercise those tests for real:

cd mcp/evidence-pinning && npm ci && npm run build

ci.yml already runs the full suite (server built) on every push/PR — this is what to run locally before pushing, to catch a failure before CI does, not a substitute for it.

Retention

Four different stores live under --data-dir, each retained (or deliberately not) on purpose — check this before assuming any of them just grows forever:

  • dedup_store.json's rolling-window entries are already pruned automatically on every orchestrate.py run (dedup_store.prune_old_entries(), wired in via rank.py, default 14-day window) — no action needed here, this one never grows unbounded.
  • episodes/<date>/ (the actual per-episode script/audio output) is not pruned automatically — a human may not have run distribute.py against a given episode yet, may want it kept as their own archive, or may be actively debugging it, so silent automatic deletion felt like the wrong default here. Use prune_episodes.py explicitly instead:
    python ${CLAUDE_PLUGIN_ROOT}/scripts/prune_episodes.py --data-dir ~/.broadcast-data [--retention-days N]  # default: 90
    Dry-run by default — it only prints what would be deleted and changes nothing on disk. Pass --apply to actually remove stale episode directories.
  • evidence_store/ (evidence-pinning-mcp's own state) is deliberately never pruned by anything in this pipeline — it's an append-only provenance log by design (see mcp/evidence-pinning/README.md), meant to keep a claim's full history queryable indefinitely. Pruning it would defeat its actual purpose, not just free disk space.
  • audio_cache/ (per-segment synthesized-audio cache, see "Running one episode" above) is not pruned automatically — no tool exists for this yet, an accepted gap for now rather than something silently ignored. It grows slowly: most of it is genuinely per-day (each story's own text is new), but fixed boilerplate (the disclosure segment) permanently dedupes to a single entry across every episode ever run.

Known operational risks — read before running live

  • Gemini TTS rate-limits under realistic call volume, confirmed live more than once — most recently 11 of 14 segments failing to HTTP 429, twice on separate real runs, and a third real run (the default-sized 10-story episode) failing outright — every segment after the first few hit HTTP 429, episode_produced: false — against an unbilled (free-tier) GEMINI_API_KEY. Never retrigger a failed or in-flight run back-to-back — wait for it to fully finish first. A blind retry is itself another request competing for the same already-exhausted quota; this made a real rate-limit situation worse in this project's own history, not better. synthesize_text()'s retry policy (gemini_retry.py) already honors the server's Retry-After header; --synth-delay-seconds adds further pacing; the per-segment audio_cache/ (see "Running one episode" above) means a retry, once you do run one, only re-synthesizes segments that actually failed last time, not the whole episode. If you see a wall of HTTP 429 in synth_failed, wait — don't immediately re-run. If runs keep failing outright on a free-tier key, --top-three-count/--quick-hits-count (see "Running one episode" above) shrink the real TTS call count for the next attempt — the free lever to pull before paid Gemini quota.
  • narrate.py's pinned model was chosen from live reconnaissance, not assumed — see its module docstring for the full account (a deprecated model, two overloaded newer ones, then a working one). If narration starts failing broadly, check whether that model id is still available/healthy before assuming a code regression.

What's NOT built here

  • Scheduling exists but is off by default. .github/workflows/broadcast-scheduled.yml (see "Scheduling" above) will run orchestrate.py on a cron cadence once BROADCAST_SCHEDULE_ENABLED is set — until then, every firing is a visible no-op. It doesn't publish (distribute.py) yet either way, pending a hosting decision.
  • Hosting target chosen, publishing still manual. The intended host is the AI_Stylet repo served by GitHub Pages (its README has the one-time Pages setup and the publish command). distribute.py only writes files; copying them there, committing and pushing is a deliberate human step, and nothing here has published an episode yet.
  • Cover art exists but must be passed explicitly. AI_Stylet/docs/cover.jpg (2000x2000 JPEG) is the show art. distribute.py emits itunes:image only when given --itunes-image-url, so a publish without --itunes-image-url https://hefrock.github.io/AI_Stylet/cover.jpg ships a feed with no artwork, which Apple Podcasts will not list.
  • No cross-story "digest" synthesis. Narration is strictly per-story, isolated to that story's own text — a deliberate scope decision made after research into hallucination risk in broader-context AI summarization, not an oversight. See narrate.py's docstring for the reasoning.
  • No dollar-cost tracking. --dry-run (see above) gives a pre-flight call-count estimate (exact for embeddings, a safe upper bound for narration/TTS), which is what actually would have prevented this project's own real quota-exhaustion incidents — but it doesn't know or report actual pricing, and there's still no cumulative spend tracking across runs.
  • Retention for episodes/<date>/ is opt-in, not automatic — see the Retention section above; prune_episodes.py exists but has to be run deliberately, nothing calls it on a schedule.

Output discipline

  • Always report qa_passed, episode_produced, and the narration/synth failure buckets together when summarizing a run — a summary that omits them can make a partially-failed episode look clean.
  • Never say an episode was "published" unless distribute.py has run and its --publish-dir output has actually been deployed somewhere reachable at --base-url. Writing feed.xml locally is not publishing.
  • A low narration success rate is informational, not an error — don't treat it with the same urgency as a QA or synthesis failure.
  • If synth_failed shows HTTP 429, say so plainly and recommend waiting — don't immediately re-run the episode (see Known operational risks).

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.