[object Object]

← back to Allnewsdaily

allnewsdaily: daily YouTube Short pipeline (headlines→vertical Short→unlisted upload)

9eb9cfafb7cb115712916e03d92778ff240c0154 · 2026-09-09 16:04:44 -0700 · Steve Abrams

Fans wire.json top-6 headlines through pick→script→ElevenLabs VO→ffmpeg/ImageMagick
render→resumable unlisted YouTube upload, on a daily launchd job (not yet loaded).
Includes Cody/DTD red-team hardening: feed-freshness guard (refuse stale news),
cross-day dedupe, fleet-visible PASS/WARN/FAIL canary, launchd-safe node/PATH,
resumable-resume upload retry, thumbnail upload, same-day lock, single-clock dates.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 9eb9cfafb7cb115712916e03d92778ff240c0154
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed Sep 9 16:04:44 2026 -0700

    allnewsdaily: daily YouTube Short pipeline (headlines→vertical Short→unlisted upload)
    
    Fans wire.json top-6 headlines through pick→script→ElevenLabs VO→ffmpeg/ImageMagick
    render→resumable unlisted YouTube upload, on a daily launchd job (not yet loaded).
    Includes Cody/DTD red-team hardening: feed-freshness guard (refuse stale news),
    cross-day dedupe, fleet-visible PASS/WARN/FAIL canary, launchd-safe node/PATH,
    resumable-resume upload retry, thumbnail upload, same-day lock, single-clock dates.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 .gitignore                               |   1 +
 com.steve.allnewsdaily-daily-short.plist |  47 +++++
 scripts/short/CONTRACTS.md               |  82 +++++++++
 scripts/short/build-script.js            | 108 ++++++++++++
 scripts/short/make-daily-short.mjs       | 159 +++++++++++++++++
 scripts/short/pick-stories.js            | 159 +++++++++++++++++
 scripts/short/render-short.js            | 292 +++++++++++++++++++++++++++++++
 scripts/short/tts-elevenlabs.mjs         |  64 +++++++
 scripts/short/upload-youtube.mjs         | 234 +++++++++++++++++++++++++
 scripts/short/youtube-auth.mjs           | 264 ++++++++++++++++++++++++++++
 10 files changed, 1410 insertions(+)

diff --git a/.gitignore b/.gitignore
index 9981df4..489c960 100644
--- a/.gitignore
+++ b/.gitignore
@@ -13,3 +13,4 @@ data/live-status.json
 data/paraphrase-cache.json
 data/wire.json
 data/live-static.flag
+data/short/
diff --git a/com.steve.allnewsdaily-daily-short.plist b/com.steve.allnewsdaily-daily-short.plist
new file mode 100644
index 0000000..0bd363c
--- /dev/null
+++ b/com.steve.allnewsdaily-daily-short.plist
@@ -0,0 +1,47 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<!-- allnewsdaily daily YouTube Short (TK-11342). Renders the top ~6 wire.json headlines into a
+     vertical <60s Short (ElevenLabs VO + branded cards) and uploads it UNLISTED for Steve to promote.
+     NOT loaded automatically — installing a recurring auto-publish + metered job is Steve-gated, and
+     it requires YOUTUBE_REFRESH_TOKEN (run youtube-auth.mjs first).
+     Load:   launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.steve.allnewsdaily-daily-short.plist
+     Unload: launchctl bootout   gui/$(id -u) ~/Library/LaunchAgents/com.steve.allnewsdaily-daily-short.plist -->
+<plist version="1.0">
+<dict>
+  <key>Label</key>
+  <string>com.steve.allnewsdaily-daily-short</string>
+
+  <key>ProgramArguments</key>
+  <array>
+    <string>/opt/homebrew/bin/node</string>
+    <string>/Users/macstudio3/Projects/allnewsdaily/scripts/short/make-daily-short.mjs</string>
+  </array>
+
+  <!-- Cody #2: launchd strips PATH to /usr/bin:/bin — put Homebrew first so node/ffmpeg/ffprobe/magick resolve. -->
+  <key>EnvironmentVariables</key>
+  <dict>
+    <key>PATH</key>
+    <string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
+    <key>HOME</key>
+    <string>/Users/macstudio3</string>
+  </dict>
+
+  <key>WorkingDirectory</key>
+  <string>/Users/macstudio3/Projects/allnewsdaily</string>
+
+  <!-- 6:00 AM local, daily. -->
+  <key>StartCalendarInterval</key>
+  <dict>
+    <key>Hour</key><integer>6</integer>
+    <key>Minute</key><integer>0</integer>
+  </dict>
+
+  <key>RunAtLoad</key>
+  <false/>
+
+  <key>StandardOutPath</key>
+  <string>/Users/macstudio3/Projects/allnewsdaily/data/short/daily-short.log</string>
+  <key>StandardErrorPath</key>
+  <string>/Users/macstudio3/Projects/allnewsdaily/data/short/daily-short.log</string>
+</dict>
+</plist>
diff --git a/scripts/short/CONTRACTS.md b/scripts/short/CONTRACTS.md
new file mode 100644
index 0000000..f2a1d34
--- /dev/null
+++ b/scripts/short/CONTRACTS.md
@@ -0,0 +1,82 @@
+# allnewsdaily daily-Short pipeline — shared interface contract (TK-11342)
+
+All stages live in `~/Projects/allnewsdaily/scripts/short/`. Generated media goes in
+`~/Projects/allnewsdaily/data/short/` (gitignored — do not commit media/artifacts).
+Node is v26 (built-in `fetch` available; **use raw fetch, add no heavy npm deps**).
+
+## INPUT — `~/Projects/allnewsdaily/data/wire.json` (already exists, refreshed every 2 min)
+```
+{
+  updatedAt: ISO,
+  splash: { outlet, link, date, topic },              // the LEAD story
+  columns: [ { key, title, items:[ {outlet,link,date,topic} ], ok, total } ]  // 3 columns
+}
+```
+**CRITICAL, verified 2026-09-09:** there is **NO `title` field**. `item.topic` **IS the
+headline sentence** (a full sentence, e.g. "Google has announced a €13 billion investment…").
+`item.outlet` = source name. `item.link` = article URL. `item.date` may be RSS or ISO format.
+
+## STAGE 1 — `pick-stories.js`  (owner: content subagent)
+Read wire.json → select **6** stories: `splash` first, then the strongest items across the 3
+columns. Dedupe by outlet (max 2 per outlet). Skip items whose `topic` is empty or < 25 chars.
+Write `data/short/stories.json`:
+```
+{ date:"YYYY-MM-DD", generatedAt:ISO,
+  stories:[ { n:1, headline:<item.topic, trimmed>, outlet, link, tag:<column title or "Top"> }, … 6 ] }
+```
+`node scripts/short/pick-stories.js` also prints the 6 chosen headlines.
+
+## STAGE 2 — `build-script.js`  (owner: content subagent)
+Read stories.json → build a **45–55s** narration for a vertical Short. FACTUAL news-brief tone,
+**no opinion/editorializing**. Intro: "All News Daily. Your headlines for {Month D}." Each beat:
+"{headline} — via {outlet}." Outro: "That's your briefing. Full stories and live coverage at
+all news daily dot com." Pace ≈ 2.7 words/sec. Hard cap totalSec ≤ 58 (Shorts must be < 60s);
+if over, drop to 5 beats. Write `data/short/script.json`:
+```
+{ intro:{text,estSec}, beats:[{n,headline,outlet,text,estSec}], outro:{text,estSec},
+  totalSec, narration:"<full flat text, newline between segments>" }
+```
+
+## STAGE 3 — `render-short.js`  (owner: render subagent — the ffmpeg heavy-lift)
+Read stories.json + script.json + `data/short/vo.mp3` (voiceover; if absent, render silent + WARN).
+Produce `data/short/out.mp4`: **1080×1920, H.264/yuv420p, 30fps, < 60s, AAC audio**.
+- Use **ffmpeg `drawtext`** (NOT node-canvas — avoid the native cairo build). Bundled font:
+  `/System/Library/Fonts/Supplemental/Arial Bold.ttf` (fallback Helvetica).
+- Per beat = a branded card: dark bg (#0a0a0a / subtle gradient), top wordmark **ALL NEWS DAILY**
+  with a red accent bar, the **headline word-wrapped & centered** (wrap ≈ 22–26 chars/line),
+  the **outlet** as a chip lower-third, and a subtle Ken-Burns zoompan.
+- Sync card durations to the REAL VO length: `ffprobe` vo.mp3 for duration, distribute across
+  intro+beats+outro proportional to each segment's estSec.
+- Also emit `data/short/thumb.jpg` (a strong first/lead-card frame, 1080×1920).
+- Robust: check ffmpeg exists; fail LOUD with a clear message. `node scripts/short/render-short.js`.
+
+## STAGE 4 — `youtube-auth.js` + `upload-youtube.js`  (owner: upload/auth subagent)
+Creds from `~/Projects/secrets-manager/.env`: `YOUTUBE_CLIENT_ID`, `YOUTUBE_CLIENT_SECRET`.
+- **`youtube-auth.js`** — OAuth 2.0 **loopback** flow. Scope
+  `https://www.googleapis.com/auth/youtube.upload` + `.../youtube.readonly`. Start a localhost
+  server on `http://localhost:9964/oauth2callback`, PRINT the consent URL for Steve to open,
+  catch the `code`, exchange for tokens, and SAVE `refresh_token` to
+  `~/Projects/allnewsdaily/.env` as `YOUTUBE_REFRESH_TOKEN=` (that file is gitignored; note in
+  output that it should also be routed via the `secrets` skill). On success, call
+  `channels.list?mine=true&part=snippet` and PRINT the authorized channel title (confirms WHICH
+  channel). If token exchange returns `redirect_uri_mismatch`, print a clear instruction that
+  `http://localhost:9964/oauth2callback` must be added as an authorized redirect URI on the
+  OAuth client in Google Cloud Console. Raw fetch, no googleapis dep.
+- **`upload-youtube.js`** — export `async uploadShort({file,title,description,tags,privacyStatus='unlisted'})`.
+  Refresh an access token from `YOUTUBE_REFRESH_TOKEN`, then do a **resumable** upload: POST
+  `https://www.googleapis.com/upload/youtube/v3/videos?uploadType=resumable&part=snippet,status`
+  with JSON metadata (snippet.title/description/tags/categoryId=**25** News&Politics;
+  status.privacyStatus; status.selfDeclaredMadeForKids=**false**), then PUT the file bytes to the
+  returned `Location`. Return `{videoId, url}`. CLI: `node scripts/short/upload-youtube.js --file
+  data/short/out.mp4 --title "…" --dry-run` (dry-run prints metadata, no upload; used for testing
+  before the OAuth token exists).
+
+## STAGE 5 — `make-daily-short.js` (orchestrator — owned by the merge step, do NOT build)
+Chains 1→2→ElevenLabs VO→3→4, logs cost, writes the daily canary `data/latest.json`
+(PASS/WARN/FAIL). The subagents do NOT build this; leave it to integration.
+
+## Rules for all subagents
+- Work under **TK-11342**; `tk log TK-11342 "…"` your actions. Do NOT `git commit` (merge step commits).
+- Self-test what you can WITHOUT spend or the OAuth token (dry-runs, silent-VO render, unit checks).
+- No editorializing in any narration/metadata — this is a news channel; facts + attribution only.
+- Do not fetch or embed the outlets' own images/video (copyright) — our branded cards only.
diff --git a/scripts/short/build-script.js b/scripts/short/build-script.js
new file mode 100644
index 0000000..6eebed6
--- /dev/null
+++ b/scripts/short/build-script.js
@@ -0,0 +1,108 @@
+#!/usr/bin/env node
+/**
+ * STAGE 2 — build-script.js  (TK-11342)
+ * Read data/short/stories.json -> build a 45–55s FACTUAL news-brief narration
+ * for a vertical Short (no opinion/editorializing). Write data/short/script.json
+ * per CONTRACTS.md. Hard cap totalSec <= 58; if over, drop to 5 beats.
+ *
+ * Pace ~ 2.7 words/sec.
+ */
+'use strict';
+const fs = require('fs');
+const path = require('path');
+
+const ROOT = path.resolve(__dirname, '..', '..');
+const IN = path.join(ROOT, 'data', 'short', 'stories.json');
+const OUT_DIR = path.join(ROOT, 'data', 'short');
+const OUT = path.join(OUT_DIR, 'script.json');
+
+const WPS = 2.7;        // words per second
+const HARD_CAP = 58;    // seconds — Shorts must be < 60s
+
+function wordCount(s) {
+  return clean(s).split(/\s+/).filter(Boolean).length;
+}
+function clean(s) {
+  return (s == null ? '' : String(s)).replace(/\s+/g, ' ').trim();
+}
+function estSec(text) {
+  return Math.round((wordCount(text) / WPS) * 10) / 10; // 1 decimal
+}
+// "Month D" from a YYYY-MM-DD stamp (local-safe, no TZ shift).
+function monthDay(dateStr) {
+  let d;
+  if (/^\d{4}-\d{2}-\d{2}$/.test(dateStr || '')) {
+    const [y, m, day] = dateStr.split('-').map(Number);
+    d = new Date(y, m - 1, day);
+  } else {
+    d = new Date();
+  }
+  return d.toLocaleDateString('en-US', { month: 'long', day: 'numeric' });
+}
+// Ensure a headline sentence ends with terminal punctuation before "— via".
+function normHeadline(h) {
+  const t = clean(h);
+  return /[.!?]$/.test(t) ? t : t + '.';
+}
+
+function buildBeats(stories) {
+  return stories.map((s, i) => {
+    const text = `${normHeadline(s.headline)} — via ${clean(s.outlet)}.`;
+    return { n: i + 1, headline: clean(s.headline), outlet: clean(s.outlet), text, estSec: estSec(text) };
+  });
+}
+
+function assemble(intro, beats, outro) {
+  const total = Math.round((intro.estSec + beats.reduce((a, b) => a + b.estSec, 0) + outro.estSec) * 10) / 10;
+  const narration = [intro.text, ...beats.map((b) => b.text), outro.text].join('\n');
+  return { intro, beats, outro, totalSec: total, narration };
+}
+
+function main() {
+  if (!fs.existsSync(IN)) {
+    console.error(`[build-script] FATAL: stories.json not found at ${IN}. Run pick-stories.js first.`);
+    process.exit(1);
+  }
+  const data = JSON.parse(fs.readFileSync(IN, 'utf8'));
+  const stories = Array.isArray(data.stories) ? data.stories : [];
+  if (stories.length === 0) {
+    console.error('[build-script] FATAL: stories.json has no stories.');
+    process.exit(1);
+  }
+
+  const introText = `All News Daily. Your headlines for ${monthDay(data.date)}.`;
+  const outroText = `That's your briefing. Full stories and live coverage at all news daily dot com.`;
+  const intro = { text: introText, estSec: estSec(introText) };
+  const outro = { text: outroText, estSec: estSec(outroText) };
+
+  let beats = buildBeats(stories);
+  let script = assemble(intro, beats, outro);
+
+  // Hard cap: if over 58s, drop to 5 beats (then keep trimming as a safety net).
+  if (script.totalSec > HARD_CAP && beats.length > 5) {
+    beats = beats.slice(0, 5);
+    script = assemble(intro, beats, outro);
+    console.warn(`[build-script] over ${HARD_CAP}s cap — dropped to ${beats.length} beats.`);
+  }
+  while (script.totalSec > HARD_CAP && beats.length > 3) {
+    beats = beats.slice(0, beats.length - 1);
+    script = assemble(intro, beats, outro);
+    console.warn(`[build-script] still over cap — trimmed to ${beats.length} beats.`);
+  }
+
+  fs.mkdirSync(OUT_DIR, { recursive: true });
+  fs.writeFileSync(OUT, JSON.stringify(script, null, 2));
+
+  console.log(`[build-script] wrote script.json -> ${path.relative(ROOT, OUT)}`);
+  console.log(`  beats=${beats.length}  totalSec=${script.totalSec}  (target 45–55, cap ${HARD_CAP})`);
+  if (script.totalSec < 45) console.warn('[build-script] WARN: narration under 45s target.');
+  if (script.totalSec > 55) console.warn('[build-script] WARN: narration over 55s target (still under hard cap).');
+
+  return script;
+}
+
+if (require.main === module) {
+  try { main(); }
+  catch (e) { console.error('[build-script] FATAL:', e && e.stack || e); process.exit(1); }
+}
+module.exports = { main };
diff --git a/scripts/short/make-daily-short.mjs b/scripts/short/make-daily-short.mjs
new file mode 100644
index 0000000..fef8797
--- /dev/null
+++ b/scripts/short/make-daily-short.mjs
@@ -0,0 +1,159 @@
+#!/usr/bin/env node
+// make-daily-short.mjs — ORCHESTRATOR (integration/merge node) for the allnewsdaily daily Short. TK-11342.
+// Chains: pick-stories → build-script → ElevenLabs VO → render-short → (unlisted) YouTube upload,
+// then writes a run record + a FLEET-VISIBLE canary (PASS/WARN/FAIL).
+//
+// Cody/DTD red-team hardening (2026-09-09):
+//  - #2 launchd-safe: shells out with process.execPath (absolute node), not bare 'node'.
+//  - #3 canary ALSO written to ~/.claude/skills/allnewsdaily-daily-short/data/latest.json
+//       so fleet-health-rollup (globs skills/*/data/latest.json) actually sees a FAIL.
+//  - #7 same-day lock + "already published today" guard (no double-post / no race).
+//  - #8 single clock: title/description date comes from stories.json (pick time), not new Date().
+//  - freshness: a stale feed (pick-stories exit 2) → WARN "refused stale", not a crash.
+//  - history: aired links appended to data/short/history.json only after a real upload.
+//
+// Flags: --no-vo  --no-upload  --dry-run  --force (ignore same-day guard)  --allow-stale  --privacy <v>
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { execFileSync } from 'node:child_process';
+
+const DIR = path.dirname(fileURLToPath(import.meta.url));
+const ROOT = path.resolve(DIR, '../..');
+const DATA = path.join(ROOT, 'data', 'short');
+const HISTORY = path.join(DATA, 'history.json');
+const LOCK = path.join(DATA, '.lock');
+const HOME = process.env.HOME || '/Users/macstudio3';
+const SKILL_DATA = path.join(HOME, '.claude', 'skills', 'allnewsdaily-daily-short', 'data');
+const LOCK_TTL_MS = 30 * 60 * 1000;
+
+const has = (f) => process.argv.includes(f);
+const argv = (n, d) => { const i = process.argv.indexOf(n); return i > -1 ? process.argv[i + 1] : d; };
+const NO_VO = has('--no-vo'), NO_UPLOAD = has('--no-upload'), DRY = has('--dry-run');
+const FORCE = has('--force'), ALLOW_STALE = has('--allow-stale');
+const PRIVACY = argv('--privacy', 'unlisted');
+const todayStamp = () => new Date().toISOString().slice(0, 10);
+
+function readEnv(file, key) {
+  try { const l = fs.readFileSync(file, 'utf8').split('\n').find((x) => x.startsWith(key + '=')); return l ? l.slice(key.length + 1).trim() : null; }
+  catch { return null; }
+}
+// #2 — absolute node binary; launchd's stripped PATH has no 'node'.
+function node(script) {
+  const args = [path.join(DIR, script)];
+  if (ALLOW_STALE && script === 'pick-stories.js') args.push('--allow-stale');
+  execFileSync(process.execPath, args, { stdio: 'inherit', cwd: ROOT });
+}
+
+// #3 — write the run record locally AND a fleet-visible canary latest.json.
+function writeStatus(status, detail, extra = {}) {
+  const out = { skill: 'allnewsdaily-daily-short', ts: new Date().toISOString(), status, verdict: status, detail, ...extra };
+  fs.mkdirSync(DATA, { recursive: true });
+  fs.writeFileSync(path.join(DATA, 'latest-run.json'), JSON.stringify(out, null, 2));
+  try { fs.mkdirSync(SKILL_DATA, { recursive: true }); fs.writeFileSync(path.join(SKILL_DATA, 'latest.json'), JSON.stringify(out, null, 2)); }
+  catch (e) { console.error('[canary] could not write fleet latest.json:', e.message); }
+  const icon = status === 'PASS' ? '✓' : status === 'WARN' ? '⚠' : '✗';
+  console.log(`\n${icon} daily-short: ${status} — ${detail}`);
+  return out;
+}
+
+// #8 — one clock: the briefing date is fixed at pick time (stories.json.date).
+function buildMetadata(stories, dateStr) {
+  const d = new Date((dateStr || todayStamp()) + 'T12:00:00Z');
+  const monthDay = d.toLocaleDateString('en-US', { month: 'long', day: 'numeric', timeZone: 'UTC' });
+  const lead = (stories[0]?.headline || 'Today’s headlines').replace(/\s+/g, ' ').trim();
+  const leadShort = lead.length > 60 ? lead.slice(0, 57).trimEnd() + '…' : lead;
+  const title = `All News Daily — ${monthDay}: ${leadShort}`.slice(0, 98);
+  const lines = stories.map((s, i) => `${i + 1}. ${s.headline} — ${s.outlet}\n   ${s.link}`);
+  const outlets = [...new Set(stories.map((s) => s.outlet))];
+  const description = [
+    `Your ${monthDay} headlines from All News Daily.`, '',
+    ...lines, '',
+    'Full stories + live coverage: https://allnewsdaily.com', '',
+    `Sources: ${outlets.join(', ')}`,
+    'Headlines are summarized with source attribution; all reporting belongs to the outlets linked above.', '',
+    '#Shorts #news #headlines #dailynews #worldnews #currentevents',
+  ].join('\n');
+  const tags = ['news', 'headlines', 'daily news', 'world news', 'breaking news', 'current events', 'allnewsdaily', 'news brief', ...outlets].slice(0, 30);
+  return { title, description, tags, privacyStatus: PRIVACY };
+}
+
+function appendHistory(dateStr, links) {
+  let h = [];
+  try { h = JSON.parse(fs.readFileSync(HISTORY, 'utf8')); if (!Array.isArray(h)) h = []; } catch {}
+  h.push({ date: dateStr, ts: new Date().toISOString(), links });
+  fs.writeFileSync(HISTORY, JSON.stringify(h.slice(-14), null, 2)); // keep ~2 weeks
+}
+
+// #7 — lock so a manual test can't collide with the cron.
+function acquireLock() {
+  try {
+    const st = fs.statSync(LOCK);
+    if (Date.now() - st.mtimeMs < LOCK_TTL_MS) return false; // fresh lock held
+  } catch {}
+  fs.mkdirSync(DATA, { recursive: true });
+  fs.writeFileSync(LOCK, JSON.stringify({ pid: process.pid, ts: new Date().toISOString() }));
+  return true;
+}
+const releaseLock = () => { try { fs.rmSync(LOCK); } catch {} };
+
+(async () => {
+  if (!acquireLock()) { console.error('[daily-short] another run holds the lock (< 30min old) — aborting.'); process.exit(0); }
+  try {
+    // #7 — already published today?
+    if (!FORCE && !NO_UPLOAD && !DRY) {
+      try {
+        const prev = JSON.parse(fs.readFileSync(path.join(DATA, 'latest-run.json'), 'utf8'));
+        if (prev.airedDate === todayStamp() && prev.videoId) { releaseLock(); return void writeStatus('PASS', `already published today (${prev.url}) — skipping (use --force to re-run)`, prev); }
+      } catch {}
+    }
+
+    console.log('▸ 1/5 pick-stories');
+    try { node('pick-stories.js'); }
+    catch (e) {
+      if (e.status === 2) { releaseLock(); return void writeStatus('WARN', 'feed stale/unverifiable — refused to publish stale news (wire-refresh loop likely dead). Use --allow-stale to override.', { costUSD: 0 }); }
+      throw e;
+    }
+    console.log('▸ 2/5 build-script'); node('build-script.js');
+
+    const storiesDoc = JSON.parse(fs.readFileSync(path.join(DATA, 'stories.json'), 'utf8'));
+    const stories = storiesDoc.stories;
+    const script = JSON.parse(fs.readFileSync(path.join(DATA, 'script.json'), 'utf8'));
+    if (!stories.length) { releaseLock(); return void writeStatus('FAIL', 'no eligible stories selected — nothing to render', { costUSD: 0 }); }
+
+    let cost = 0, voNote = 'silent (--no-vo)';
+    if (!NO_VO) {
+      console.log('▸ 3/5 ElevenLabs voiceover');
+      const { synthesize } = await import('./tts-elevenlabs.mjs');
+      const r = await synthesize({ text: script.narration, out: path.join(DATA, 'vo.mp3') });
+      cost = r.costUSD; voNote = `${r.chars} chars, ~$${r.costUSD} (${r.voice}/${r.model})`;
+      console.log(`  VO: ${voNote}`);
+    } else { try { fs.rmSync(path.join(DATA, 'vo.mp3')); } catch {} }
+
+    console.log('▸ 4/5 render'); node('render-short.js');
+    const mp4 = path.join(DATA, 'out.mp4');
+    const thumb = path.join(DATA, 'thumb.jpg');
+    if (!fs.existsSync(mp4)) { releaseLock(); return void writeStatus('FAIL', 'render produced no out.mp4', { costUSD: cost }); }
+
+    const meta = buildMetadata(stories, storiesDoc.date);
+    console.log('▸ 5/5 upload');
+    if (NO_UPLOAD) { releaseLock(); return void writeStatus('PASS', `rendered ${mp4} — upload skipped (--no-upload)`, { costUSD: cost, voNote, title: meta.title }); }
+
+    const token = process.env.YOUTUBE_REFRESH_TOKEN || readEnv(path.join(ROOT, '.env'), 'YOUTUBE_REFRESH_TOKEN');
+    if (!token && !DRY) { releaseLock(); return void writeStatus('WARN', 'rendered OK but NO YOUTUBE_REFRESH_TOKEN — run `node scripts/short/youtube-auth.mjs` (one-time login) then re-run', { costUSD: cost, voNote, title: meta.title }); }
+
+    const { uploadShort } = await import('./upload-youtube.mjs');
+    if (DRY) { console.log('DRY-RUN upload metadata:\n' + JSON.stringify(meta, null, 2)); releaseLock(); return void writeStatus('PASS', 'dry-run — rendered + metadata built, no upload', { costUSD: cost, voNote, title: meta.title }); }
+
+    const res = await uploadShort({ file: mp4, thumbnail: fs.existsSync(thumb) ? thumb : undefined, ...meta });
+    appendHistory(storiesDoc.date, stories.map((s) => s.link).filter(Boolean)); // record only after a real upload
+    releaseLock();
+    return void writeStatus('PASS', `uploaded ${PRIVACY}: ${res.url} (thumbnail: ${res.thumbnail.note})`, {
+      costUSD: cost, voNote, title: meta.title, videoId: res.videoId, url: res.url, airedDate: storiesDoc.date,
+    });
+  } catch (e) {
+    releaseLock();
+    writeStatus('FAIL', 'pipeline error: ' + e.message);
+    process.exit(1);
+  }
+})();
diff --git a/scripts/short/pick-stories.js b/scripts/short/pick-stories.js
new file mode 100644
index 0000000..299fff0
--- /dev/null
+++ b/scripts/short/pick-stories.js
@@ -0,0 +1,159 @@
+#!/usr/bin/env node
+/**
+ * STAGE 1 — pick-stories.js  (TK-11342)
+ * Read data/wire.json -> select 6 stories (splash first, then strongest column
+ * items across the 3 columns), dedupe by outlet (max 2/outlet), skip topics
+ * that are empty or < 25 chars. Write data/short/stories.json per CONTRACTS.md
+ * and print the 6 chosen headlines.
+ *
+ * NOTE (verified 2026-09-09): wire.json items have NO `title` field —
+ * `item.topic` IS the headline sentence.
+ *
+ * Cody/DTD red-team hardening (2026-09-09):
+ *  - FRESHNESS GUARD (#1): refuse to run if wire.updatedAt is > MAX_STALE_MIN old,
+ *    so a dead feed-refresh loop can never render stale news as "today's briefing"
+ *    (the silent-wrong-success failure). Exit code 2. Override with --allow-stale.
+ *  - CROSS-DAY DEDUPE (#1): exclude links aired in the last HISTORY_DAYS days
+ *    (data/short/history.json, written by the orchestrator after a successful
+ *    upload). Relaxes to refill if a slow-news day leaves < TARGET eligible.
+ *  - DEFENSIVE ENTITY DECODE (#10b): decode HTML entities in headlines so a feed
+ *    regression can't put "Apple &amp;amp; Google" on a public card.
+ */
+'use strict';
+const fs = require('fs');
+const path = require('path');
+
+const ROOT = path.resolve(__dirname, '..', '..');
+const WIRE = path.join(ROOT, 'data', 'wire.json');
+const OUT_DIR = path.join(ROOT, 'data', 'short');
+const OUT = path.join(OUT_DIR, 'stories.json');
+const HISTORY = path.join(OUT_DIR, 'history.json');
+
+const MIN_TOPIC_LEN = 25;
+const MAX_PER_OUTLET = 2;
+const TARGET = 6;
+const MAX_STALE_MIN = Number(process.env.AND_MAX_STALE_MIN || 30); // reject feeds older than this
+const HISTORY_DAYS = Number(process.env.AND_HISTORY_DAYS || 2);    // don't re-air links aired in last N days
+const ALLOW_STALE = process.argv.includes('--allow-stale');
+
+const ENT = { '&amp;': '&', '&lt;': '<', '&gt;': '>', '&quot;': '"', '&#39;': "'", '&apos;': "'" };
+function decodeEntities(s) {
+  return s
+    .replace(/&(amp|lt|gt|quot|#39|apos);/g, (m) => ENT[m])
+    .replace(/&#(\d+);/g, (_, d) => { try { return String.fromCodePoint(+d); } catch { return _; } })
+    .replace(/&#x([0-9a-f]+);/gi, (_, h) => { try { return String.fromCodePoint(parseInt(h, 16)); } catch { return _; } });
+}
+function clean(s) {
+  return decodeEntities(s == null ? '' : String(s)).replace(/\s+/g, ' ').trim();
+}
+
+function dateStamp(iso) {
+  const d = iso ? new Date(iso) : new Date();
+  const use = isNaN(d.getTime()) ? new Date() : d;
+  return use.toISOString().slice(0, 10); // YYYY-MM-DD
+}
+
+// Links aired in the last HISTORY_DAYS days (the orchestrator appends after a real upload).
+function loadRecentLinks() {
+  try {
+    const h = JSON.parse(fs.readFileSync(HISTORY, 'utf8'));
+    const cutoff = new Date(Date.now() - HISTORY_DAYS * 86400000).toISOString().slice(0, 10);
+    const set = new Set();
+    (Array.isArray(h) ? h : []).forEach((d) => {
+      if (d && d.date >= cutoff) (d.links || []).forEach((l) => set.add(l));
+    });
+    return set;
+  } catch { return new Set(); }
+}
+
+function main() {
+  if (!fs.existsSync(WIRE)) {
+    console.error(`[pick-stories] FATAL: wire.json not found at ${WIRE}`);
+    process.exit(1);
+  }
+  const wire = JSON.parse(fs.readFileSync(WIRE, 'utf8'));
+
+  // --- FRESHNESS GUARD (#1) ---------------------------------------------------
+  const upd = Date.parse(wire.updatedAt);
+  if (!ALLOW_STALE) {
+    if (!Number.isFinite(upd)) {
+      console.error(`[pick-stories] FATAL: wire.json has no parseable updatedAt — cannot confirm freshness. Refusing to publish. (override: --allow-stale)`);
+      process.exit(2);
+    }
+    const ageMin = (Date.now() - upd) / 60000;
+    if (ageMin > MAX_STALE_MIN) {
+      console.error(`[pick-stories] FATAL: wire.json is ${ageMin.toFixed(0)}min stale (> ${MAX_STALE_MIN}min) — the feed-refresh loop is likely dead. Refusing to render stale news as today's briefing. (override: --allow-stale)`);
+      process.exit(2);
+    }
+  }
+
+  const recentLinks = loadRecentLinks();
+  const splash = wire.splash || null;
+  const columns = Array.isArray(wire.columns) ? wire.columns : [];
+
+  let allowRepeats = false; // flipped to refill if strict pass can't reach TARGET
+  const picked = [];
+  const outletCount = Object.create(null);
+  const seenLinks = new Set();
+
+  const outletKey = (o) => clean(o).toLowerCase();
+  function eligible(item) {
+    if (!item) return false;
+    const topic = clean(item.topic);
+    if (topic.length < MIN_TOPIC_LEN) return false;                     // skip short/empty
+    const link = clean(item.link);
+    if (link && seenLinks.has(link)) return false;                     // dedupe within this run
+    if (!allowRepeats && link && recentLinks.has(link)) return false;  // cross-day dedupe (#1)
+    const ok = outletKey(item.outlet);
+    if (ok && (outletCount[ok] || 0) >= MAX_PER_OUTLET) return false;  // <=2/outlet
+    return true;
+  }
+  function take(item, tag) {
+    const ok = outletKey(item.outlet);
+    if (ok) outletCount[ok] = (outletCount[ok] || 0) + 1;
+    const link = clean(item.link);
+    if (link) seenLinks.add(link);
+    picked.push({ n: picked.length + 1, headline: clean(item.topic), outlet: clean(item.outlet), link, tag: clean(tag) || 'Top' });
+  }
+
+  function selectPass() {
+    if (splash && eligible(splash)) take(splash, 'Top');
+    const cursors = columns.map(() => 0);
+    let progressed = true;
+    while (picked.length < TARGET && progressed) {
+      progressed = false;
+      for (let c = 0; c < columns.length && picked.length < TARGET; c++) {
+        const items = Array.isArray(columns[c].items) ? columns[c].items : [];
+        while (cursors[c] < items.length) {
+          const item = items[cursors[c]++];
+          progressed = true;
+          if (eligible(item)) { take(item, columns[c].title); break; }
+        }
+      }
+    }
+  }
+
+  selectPass();
+  // Refill: if cross-day dedupe left us short on a slow-news day, allow repeats.
+  if (picked.length < TARGET && recentLinks.size) {
+    console.warn(`[pick-stories] WARN: only ${picked.length}/${TARGET} fresh (non-repeat) stories — relaxing cross-day dedupe to refill.`);
+    allowRepeats = true;
+    selectPass();
+  }
+
+  const outDate = dateStamp(wire.updatedAt);
+  const result = { date: outDate, generatedAt: new Date().toISOString(), stories: picked };
+  fs.mkdirSync(OUT_DIR, { recursive: true });
+  fs.writeFileSync(OUT, JSON.stringify(result, null, 2));
+
+  console.log(`[pick-stories] wrote ${picked.length} stories -> ${path.relative(ROOT, OUT)} (date ${outDate}, feed ${Number.isFinite(upd) ? ((Date.now() - upd) / 60000).toFixed(0) + 'min old' : 'age?'})`);
+  if (picked.length < TARGET) console.warn(`[pick-stories] WARN: only ${picked.length}/${TARGET} eligible stories found`);
+  picked.forEach((s) => console.log(`  ${s.n}. ${s.headline} — via ${s.outlet}  [${s.tag}]`));
+  return result;
+}
+
+if (require.main === module) {
+  try { main(); }
+  catch (e) { console.error('[pick-stories] FATAL:', (e && e.stack) || e); process.exit(1); }
+}
+module.exports = { main };
diff --git a/scripts/short/render-short.js b/scripts/short/render-short.js
new file mode 100644
index 0000000..e1edab4
--- /dev/null
+++ b/scripts/short/render-short.js
@@ -0,0 +1,292 @@
+#!/usr/bin/env node
+/**
+ * STAGE 3 — render-short.js  (TK-11342)  owner: render subagent (ffmpeg heavy-lift)
+ *
+ * Reads:  data/short/stories.json + data/short/script.json + data/short/vo.mp3 (optional)
+ * Writes: data/short/out.mp4   (1080x1920, H.264/yuv420p, 30fps, <60s, AAC audio)
+ *         data/short/thumb.jpg (1080x1920, strong lead-card frame)
+ *
+ * If vo.mp3 is ABSENT we render a SILENT video (silent AAC track) and print a WARN.
+ * Every card is a branded ALL NEWS DAILY vertical card built purely with ffmpeg drawtext
+ * (no node-canvas / cairo). We do NOT embed any outlet imagery — branded cards only.
+ */
+
+'use strict';
+
+const fs = require('fs');
+const path = require('path');
+const { execFileSync } = require('child_process');
+
+// ---------------------------------------------------------------------------
+// Paths
+// ---------------------------------------------------------------------------
+const ROOT = path.resolve(__dirname, '..', '..');            // ~/Projects/allnewsdaily
+const DATA = path.join(ROOT, 'data', 'short');
+const TMP = path.join(DATA, '.rtmp');
+
+const STORIES_PATH = path.join(DATA, 'stories.json');
+const SCRIPT_PATH = path.join(DATA, 'script.json');
+const VO_PATH = path.join(DATA, 'vo.mp3');
+const OUT_PATH = path.join(DATA, 'out.mp4');
+const THUMB_PATH = path.join(DATA, 'thumb.jpg');
+
+// ---------------------------------------------------------------------------
+// Canvas / brand constants
+// ---------------------------------------------------------------------------
+const W = 1080, H = 1920, FPS = 30;
+const HARD_CAP_SEC = 58;          // Shorts must be < 60s; keep a safety margin
+const RED = '0xE10600';
+const INK = '0xF6F6F6';
+const SUB = '0xB8B8B8';
+const WRAP_CHARS = 24;            // ~22–26 chars/line target
+const FONT_PRIMARY = '/System/Library/Fonts/Supplemental/Arial Bold.ttf';
+const FONT_FALLBACK = '/System/Library/Fonts/Helvetica.ttc';
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+function die(msg) {
+  console.error('\n[render-short] FATAL: ' + msg + '\n');
+  process.exit(1);
+}
+
+function which(bin) {
+  // Honor env override, then homebrew, then PATH.
+  const envKey = bin.toUpperCase();
+  if (process.env[envKey] && fs.existsSync(process.env[envKey])) return process.env[envKey];
+  const brew = '/opt/homebrew/bin/' + bin;
+  if (fs.existsSync(brew)) return brew;
+  try {
+    const p = execFileSync('/usr/bin/which', [bin], { encoding: 'utf8' }).trim();
+    if (p) return p;
+  } catch (_) { /* fall through */ }
+  return null;
+}
+
+function run(bin, args) {
+  return execFileSync(bin, args, { stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf8' });
+}
+
+function readJSON(p, label) {
+  if (!fs.existsSync(p)) die(`missing required input ${label} at ${p}`);
+  try { return JSON.parse(fs.readFileSync(p, 'utf8')); }
+  catch (e) { die(`could not parse ${label} (${p}): ${e.message}`); }
+}
+
+// ---------------------------------------------------------------------------
+// Boot: verify tools, inputs, tmp dir, font
+// ---------------------------------------------------------------------------
+const FFMPEG = which('ffmpeg');
+const FFPROBE = which('ffprobe');
+if (!FFMPEG) die('ffmpeg not found (checked $FFMPEG, /opt/homebrew/bin, PATH). Install: brew install ffmpeg');
+if (!FFPROBE) die('ffprobe not found (checked $FFPROBE, /opt/homebrew/bin, PATH). Install: brew install ffmpeg');
+
+// Text is composited with ImageMagick (freetype), NOT node-canvas/cairo. We use this
+// because THIS ffmpeg build was compiled without --enable-libfreetype, so the `drawtext`
+// filter is unavailable (verified: `ffmpeg -filters` has no drawtext). ffmpeg still does
+// all the video work (Ken-Burns zoompan, concat, AAC mux); IM only renders the card PNGs.
+const MAGICK = which('magick') || which('convert');
+const HAS_DRAWTEXT = (() => {
+  try { return /(^|\s)drawtext(\s|$)/m.test(run(FFMPEG, ['-hide_banner', '-filters'])); }
+  catch (_) { return false; }
+})();
+if (!MAGICK && !HAS_DRAWTEXT) {
+  die('no text renderer available: this ffmpeg lacks the drawtext filter AND ImageMagick ' +
+    '(`magick`/`convert`) is not installed. Install one: brew install imagemagick  (or an ' +
+    'ffmpeg built --enable-libfreetype).');
+}
+if (!HAS_DRAWTEXT) console.warn('[render-short] NOTE: ffmpeg has no drawtext filter — compositing card text with ImageMagick.');
+
+const stories = readJSON(STORIES_PATH, 'stories.json');
+const script = readJSON(SCRIPT_PATH, 'script.json');
+if (!script.beats || !Array.isArray(script.beats) || !script.beats.length) die('script.json has no beats[]');
+if (!script.intro || !script.outro) die('script.json missing intro/outro');
+
+fs.rmSync(TMP, { recursive: true, force: true });
+fs.mkdirSync(TMP, { recursive: true });
+
+// Resolve the branded font (Arial Bold, Helvetica fallback). Passed as a plain CLI arg to
+// ImageMagick, so the space in the path is fine (no filtergraph escaping needed).
+const FONT = fs.existsSync(FONT_PRIMARY) ? FONT_PRIMARY
+  : (fs.existsSync(FONT_FALLBACK) ? FONT_FALLBACK : null);
+if (!FONT) die(`no usable font (looked for "${FONT_PRIMARY}" then "${FONT_FALLBACK}")`);
+if (FONT === FONT_FALLBACK) console.warn('[render-short] WARN: Arial Bold not found — using Helvetica fallback.');
+
+// ---------------------------------------------------------------------------
+// VO detection + duration → target total
+// ---------------------------------------------------------------------------
+let voDur = null, silent = true;
+if (fs.existsSync(VO_PATH) && fs.statSync(VO_PATH).size > 0) {
+  try {
+    const out = run(FFPROBE, ['-v', 'error', '-show_entries', 'format=duration',
+      '-of', 'default=noprint_wrappers=1:nokey=1', VO_PATH]).trim();
+    const d = parseFloat(out);
+    if (isFinite(d) && d > 0) { voDur = d; silent = false; }
+  } catch (e) { console.warn('[render-short] WARN: ffprobe on vo.mp3 failed: ' + e.message); }
+}
+
+const scriptTotal = Number(script.totalSec) || null;
+let target;
+if (!silent) {
+  target = voDur;
+  console.log(`[render-short] VO found: vo.mp3 = ${voDur.toFixed(2)}s — cards will sync to real audio.`);
+} else {
+  target = scriptTotal || script.intro.estSec + script.outro.estSec +
+    script.beats.reduce((s, b) => s + (Number(b.estSec) || 0), 0);
+  console.warn(`[render-short] WARN: no vo.mp3 — rendering SILENT. Using script timing (~${target.toFixed(1)}s). ` +
+    `(TTS is skipped to avoid spend; STAGE 5 orchestrator supplies real VO.)`);
+}
+if (target > HARD_CAP_SEC) {
+  console.warn(`[render-short] WARN: target ${target.toFixed(1)}s > ${HARD_CAP_SEC}s cap — compressing card durations to stay < 60s.`);
+  target = HARD_CAP_SEC;
+}
+
+// ---------------------------------------------------------------------------
+// Build the segment list (intro + beats + outro) and distribute durations
+// proportional to each segment's estSec, scaled so the sum == target.
+// ---------------------------------------------------------------------------
+function cleanIntro(t) { return String(t).replace(/^\s*All News Daily\.\s*/i, '').trim() || t; }
+
+const segments = [];
+segments.push({ kind: 'intro', main: cleanIntro(script.intro.text), outlet: null,
+  est: Number(script.intro.estSec) || 3, mainSize: 62, tag: 'BRIEFING' });
+for (const b of script.beats) {
+  segments.push({ kind: 'beat', main: b.headline || b.text, outlet: b.outlet || null,
+    est: Number(b.estSec) || 5, mainSize: 74, n: b.n });
+}
+segments.push({ kind: 'outro', main: script.outro.text, outlet: null,
+  est: Number(script.outro.estSec) || 5, mainSize: 60, tag: 'ALLNEWSDAILY.COM' });
+
+const estSum = segments.reduce((s, x) => s + x.est, 0);
+let durs = segments.map(x => Math.max(1.4, +(target * x.est / estSum).toFixed(3)));
+// Rescale after the per-card minimum clamp so the sum lands on target and stays < cap.
+let dsum = durs.reduce((a, b) => a + b, 0);
+if (dsum > HARD_CAP_SEC || Math.abs(dsum - target) > 0.05) {
+  const scale = Math.min(target, HARD_CAP_SEC) / dsum;
+  durs = durs.map(d => +(d * scale).toFixed(3));
+  dsum = durs.reduce((a, b) => a + b, 0);
+}
+console.log(`[render-short] ${segments.length} cards, total ${dsum.toFixed(2)}s (target ${target.toFixed(2)}s).`);
+
+// ---------------------------------------------------------------------------
+// Build one branded card as a supersampled PNG (1350x2400 → crisp when zoompan'd
+// down into 1080x1920). Text is composited with ImageMagick; @file reads sidestep
+// all shell/escaping issues for €, $, ', — etc. Headline uses caption: with a fixed
+// box so ImageMagick auto-fits the point size (implements the word-wrap requirement
+// by wrapping to width, no matter the headline length).
+// ---------------------------------------------------------------------------
+const CARD_W = 1350, CARD_H = 2400;
+const HEX_BG0 = '#0A0A0A', HEX_BG1 = '#17171B', HEX_INK = '#F6F6F6', HEX_RED = '#E10600';
+
+function writeRaw(name, str) { const p = path.join(TMP, name); fs.writeFileSync(p, String(str)); return p; }
+
+function buildCardPng(seg, idx) {
+  const png = path.join(TMP, `card${idx}.png`);
+
+  // 1) dark vertical gradient base
+  run(MAGICK, ['-size', `${CARD_W}x${CARD_H}`, `gradient:${HEX_BG0}-${HEX_BG1}`, png]);
+
+  // 2) tracked wordmark + red accent bar
+  run(MAGICK, [png, '-font', FONT, '-gravity', 'North',
+    '-fill', HEX_INK, '-pointsize', '56', '-annotate', '+0+215', 'A L L   N E W S   D A I L Y',
+    '-fill', HEX_RED, '-draw', 'rectangle 540,352 810,366', png]);
+
+  // 3) headline / main text — auto-fit inside a centered box (the word-wrap)
+  const hlFile = writeRaw(`hl${idx}.txt`, seg.main);
+  run(MAGICK, [png, '(', '-background', 'none', '-fill', HEX_INK, '-font', FONT,
+    '-size', '1160x1040', '-gravity', 'center', `caption:@${hlFile}`, ')',
+    '-gravity', 'center', '-geometry', '+0+0', '-composite', png]);
+
+  // 4) lower-third chip — outlet (beats) or a brand tag (intro/outro)
+  const chipText = seg.outlet ? ('VIA ' + String(seg.outlet).toUpperCase()) : (seg.tag || null);
+  if (chipText) {
+    const chFile = writeRaw(`chip${idx}.txt`, chipText);
+    run(MAGICK, [png, '(', '-background', HEX_RED, '-fill', 'white', '-font', FONT,
+      '-pointsize', '46', `label:@${chFile}`, '-bordercolor', HEX_RED, '-border', '28x18', ')',
+      '-gravity', 'North', '-geometry', '+0+1900', '-composite', png]);
+  }
+  return png;
+}
+
+// Render one card PNG → seg mp4 with a subtle Ken-Burns push-in (zoompan 1.0→~1.10).
+function renderCard(seg, dur, idx) {
+  const png = buildCardPng(seg, idx);
+  const segMp4 = path.join(TMP, `seg${idx}.mp4`);
+  const vf = "zoompan=z='min(1.0+0.00055*on,1.10)':d=1:" +
+    "x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)':" + `s=${W}x${H}:fps=${FPS}`;
+  run(FFMPEG, ['-y', '-loop', '1', '-framerate', String(FPS), '-t', dur.toFixed(3), '-i', png,
+    '-vf', vf, '-r', String(FPS),
+    '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '20', '-pix_fmt', 'yuv420p', '-an', segMp4]);
+  return segMp4;
+}
+
+console.log('[render-short] rendering cards…');
+const segFiles = segments.map((seg, i) => {
+  const f = renderCard(seg, durs[i], i);
+  process.stdout.write(`  card ${i + 1}/${segments.length} (${seg.kind}) ${durs[i].toFixed(2)}s\n`);
+  return f;
+});
+
+// ---------------------------------------------------------------------------
+// Concat all cards (identical params → stream copy)
+// ---------------------------------------------------------------------------
+const concatList = path.join(TMP, 'concat.txt');
+fs.writeFileSync(concatList, segFiles.map(f => `file '${f}'`).join('\n'));
+const VIDEO = path.join(TMP, 'video.mp4');
+run(FFMPEG, ['-y', '-f', 'concat', '-safe', '0', '-i', concatList, '-c', 'copy', VIDEO]);
+
+// ---------------------------------------------------------------------------
+// Mux audio → out.mp4  (real VO, or a silent AAC track if none)
+// ---------------------------------------------------------------------------
+console.log('[render-short] muxing audio → out.mp4…');
+if (!silent) {
+  run(FFMPEG, ['-y', '-i', VIDEO, '-i', VO_PATH,
+    '-map', '0:v:0', '-map', '1:a:0',
+    '-c:v', 'copy', '-c:a', 'aac', '-b:a', '192k',
+    '-shortest', '-movflags', '+faststart', OUT_PATH]);
+} else {
+  run(FFMPEG, ['-y', '-i', VIDEO,
+    '-f', 'lavfi', '-i', 'anullsrc=channel_layout=stereo:sample_rate=44100',
+    '-map', '0:v:0', '-map', '1:a:0',
+    '-c:v', 'copy', '-c:a', 'aac', '-b:a', '128k',
+    '-shortest', '-movflags', '+faststart', OUT_PATH]);
+}
+
+// ---------------------------------------------------------------------------
+// Thumbnail: a strong frame from the LEAD card (first beat = splash story)
+// ---------------------------------------------------------------------------
+const leadIdx = segments.findIndex(s => s.kind === 'beat');
+const leadSeg = segFiles[leadIdx >= 0 ? leadIdx : 0];
+const seekTo = Math.min(1.0, Math.max(0.2, (durs[leadIdx >= 0 ? leadIdx : 0]) * 0.5));
+run(FFMPEG, ['-y', '-ss', seekTo.toFixed(2), '-i', leadSeg,
+  '-frames:v', '1', '-q:v', '2', THUMB_PATH]);
+
+// ---------------------------------------------------------------------------
+// Verify & report
+// ---------------------------------------------------------------------------
+function probe(p) {
+  const out = run(FFPROBE, ['-v', 'error',
+    '-select_streams', 'v:0',
+    '-show_entries', 'stream=width,height,codec_name,avg_frame_rate,pix_fmt',
+    '-show_entries', 'format=duration,size',
+    '-of', 'default=noprint_wrappers=1', p]);
+  return out;
+}
+const rep = probe(OUT_PATH);
+const wMatch = /width=(\d+)/.exec(rep), hMatch = /height=(\d+)/.exec(rep);
+const dMatch = /duration=([\d.]+)/.exec(rep);
+const dur = dMatch ? parseFloat(dMatch[1]) : NaN;
+
+console.log('\n[render-short] ==== out.mp4 ffprobe ====');
+console.log(rep.trim());
+console.log('[render-short] ==========================');
+
+const okDims = wMatch && hMatch && +wMatch[1] === W && +hMatch[1] === H;
+const okDur = isFinite(dur) && dur < 60;
+if (!okDims) die(`out.mp4 dimensions are not ${W}x${H}`);
+if (!okDur) die(`out.mp4 duration ${dur}s is not < 60s`);
+
+console.log(`\n[render-short] OK — out.mp4: ${wMatch[1]}x${hMatch[1]}, ${dur.toFixed(2)}s (< 60s), ` +
+  `${silent ? 'SILENT AAC' : 'VO AAC'}.`);
+console.log(`[render-short] out:   ${OUT_PATH}`);
+console.log(`[render-short] thumb: ${THUMB_PATH}`);
diff --git a/scripts/short/tts-elevenlabs.mjs b/scripts/short/tts-elevenlabs.mjs
new file mode 100644
index 0000000..35ce56a
--- /dev/null
+++ b/scripts/short/tts-elevenlabs.mjs
@@ -0,0 +1,64 @@
+#!/usr/bin/env node
+// tts-elevenlabs.js — synthesize the daily-Short narration to data/short/vo.mp3 via ElevenLabs.
+// Metered (~cents/run). Reads ELEVENLABS_API_KEY from ~/Projects/secrets-manager/.env.
+// Voice defaults to Sarah (confident news-anchor tv voice); override with AND_TTS_VOICE.
+// Model eleven_turbo_v2_5 = cheapest tier with strong quality — good for a news brief.
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const HOME = process.env.HOME || '/Users/macstudio3';
+const SECRETS = path.join(HOME, 'Projects/secrets-manager/.env');
+
+function readEnv(file, key) {
+  try {
+    const line = fs.readFileSync(file, 'utf8').split('\n').find((l) => l.startsWith(key + '='));
+    return line ? line.slice(key.length + 1).trim() : null;
+  } catch { return null; }
+}
+
+const VOICE = process.env.AND_TTS_VOICE || 'EXAVITQu4vr4xnSDxMaL'; // Sarah
+const MODEL = process.env.AND_TTS_MODEL || 'eleven_turbo_v2_5';
+// eleven_turbo_v2_5 billed ~$0.50 / 1k chars at list, far less on paid tiers; we log actual chars.
+const RATE_PER_1K = Number(process.env.AND_TTS_RATE_PER_1K || 0.15);
+
+export async function synthesize({ text, out }) {
+  const KEY = process.env.ELEVENLABS_API_KEY || readEnv(SECRETS, 'ELEVENLABS_API_KEY');
+  if (!KEY) throw new Error('ELEVENLABS_API_KEY not found (env or secrets-manager/.env)');
+  if (!text || !text.trim()) throw new Error('tts: empty narration text');
+
+  const chars = text.length;
+  const estCost = +((chars / 1000) * RATE_PER_1K).toFixed(4);
+  const url = `https://api.elevenlabs.io/v1/text-to-speech/${VOICE}?output_format=mp3_44100_128`;
+  const r = await fetch(url, {
+    method: 'POST',
+    headers: { 'xi-api-key': KEY, 'content-type': 'application/json', accept: 'audio/mpeg' },
+    body: JSON.stringify({
+      text,
+      model_id: MODEL,
+      voice_settings: { stability: 0.5, similarity_boost: 0.75, style: 0.0, use_speaker_boost: true },
+    }),
+  });
+  if (!r.ok) {
+    const body = await r.text().catch(() => '');
+    throw new Error(`ElevenLabs HTTP ${r.status}: ${body.slice(0, 300)}`);
+  }
+  const buf = Buffer.from(await r.arrayBuffer());
+  fs.mkdirSync(path.dirname(out), { recursive: true });
+  fs.writeFileSync(out, buf);
+  return { out, bytes: buf.length, chars, costUSD: estCost, voice: VOICE, model: MODEL };
+}
+
+// CLI: node tts-elevenlabs.js --text "…" --out data/short/vo.mp3   (or --file data/short/script.json)
+if (process.argv[1] === fileURLToPath(import.meta.url)) {
+  const arg = (n) => { const i = process.argv.indexOf(n); return i > -1 ? process.argv[i + 1] : null; };
+  const SKILL_DIR = path.dirname(fileURLToPath(import.meta.url));
+  const out = arg('--out') || path.join(SKILL_DIR, '../../data/short/vo.mp3');
+  let text = arg('--text');
+  const file = arg('--file');
+  if (!text && file) text = JSON.parse(fs.readFileSync(file, 'utf8')).narration;
+  if (!text) { console.error('need --text or --file <script.json>'); process.exit(1); }
+  synthesize({ text, out })
+    .then((r) => console.log(`✓ VO written: ${r.out}  (${r.chars} chars, ${(r.bytes / 1024).toFixed(0)}KB, ~$${r.costUSD})`))
+    .catch((e) => { console.error('✗ TTS failed:', e.message); process.exit(1); });
+}
diff --git a/scripts/short/upload-youtube.mjs b/scripts/short/upload-youtube.mjs
new file mode 100644
index 0000000..a9e3711
--- /dev/null
+++ b/scripts/short/upload-youtube.mjs
@@ -0,0 +1,234 @@
+#!/usr/bin/env node
+// upload-youtube.mjs — resumable YouTube upload for the allnewsdaily daily-Short pipeline (TK-11342, STAGE 4).
+// Node v26, raw fetch, NO googleapis dependency.
+//
+// Exports:  async uploadShort({ file, title, description, tags, privacyStatus='unlisted', thumbnail }) -> { videoId, url }
+//
+// Cody/DTD red-team hardening (2026-09-09):
+//  - TIMEOUTS (#5): every network call has an AbortController deadline (no silent hang).
+//  - RESUMABLE RESUME (#6): a dropped byte-PUT is RESUMED from the server's received
+//    offset (Content-Range: bytes */size → 308 Range) — it NEVER re-POSTs the init,
+//    so a bad-network retry costs 0 extra quota units instead of another 1,600.
+//  - THUMBNAIL (#4): uploads data/short/thumb.jpg via thumbnails.set (best-effort).
+//  - CLI guard fixed for the .mjs rename.
+//
+// CLI:  node scripts/short/upload-youtube.mjs --file data/short/out.mp4 --title "…" [--dry-run] …
+
+import { readFileSync, existsSync, statSync } from 'node:fs';
+import { readFile } from 'node:fs/promises';
+import { homedir } from 'node:os';
+import { join, isAbsolute, basename } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const SECRETS_ENV = join(homedir(), 'Projects', 'secrets-manager', '.env');
+const APP_ENV = join(homedir(), 'Projects', 'allnewsdaily', '.env');
+const PROJECT_ROOT = join(homedir(), 'Projects', 'allnewsdaily');
+
+const TOKEN_ENDPOINT = 'https://oauth2.googleapis.com/token';
+const RESUMABLE_ENDPOINT = 'https://www.googleapis.com/upload/youtube/v3/videos?uploadType=resumable&part=snippet,status';
+const THUMBNAIL_ENDPOINT = 'https://www.googleapis.com/upload/youtube/v3/thumbnails/set';
+const CATEGORY_ID = '25'; // News & Politics
+
+const PUT_ATTEMPTS = 3;           // resume tries for the byte upload
+const T_SHORT = 30000;            // token/init/status/thumbnail timeout
+const T_PUT = 180000;             // byte-PUT timeout
+
+// fetch with an AbortController deadline
+async function tfetch(url, opts = {}, ms = T_SHORT) {
+  const ac = new AbortController();
+  const to = setTimeout(() => ac.abort(), ms);
+  try { return await fetch(url, { ...opts, signal: ac.signal }); }
+  finally { clearTimeout(to); }
+}
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+
+function parseEnv(path) {
+  const out = {};
+  if (!existsSync(path)) return out;
+  for (const raw of readFileSync(path, 'utf8').split('\n')) {
+    const line = raw.trim();
+    if (!line || line.startsWith('#')) continue;
+    const eq = line.indexOf('=');
+    if (eq === -1) continue;
+    let val = line.slice(eq + 1).trim();
+    if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) val = val.slice(1, -1);
+    out[line.slice(0, eq).trim()] = val;
+  }
+  return out;
+}
+
+function loadCreds() {
+  const secrets = parseEnv(SECRETS_ENV);
+  const app = parseEnv(APP_ENV);
+  return {
+    clientId: secrets.YOUTUBE_CLIENT_ID || process.env.YOUTUBE_CLIENT_ID,
+    clientSecret: secrets.YOUTUBE_CLIENT_SECRET || process.env.YOUTUBE_CLIENT_SECRET,
+    refreshToken: app.YOUTUBE_REFRESH_TOKEN || secrets.YOUTUBE_REFRESH_TOKEN || process.env.YOUTUBE_REFRESH_TOKEN,
+  };
+}
+
+async function refreshAccessToken({ clientId, clientSecret, refreshToken }) {
+  if (!clientId || !clientSecret) throw new Error(`Missing YOUTUBE_CLIENT_ID/SECRET in ${SECRETS_ENV}`);
+  if (!refreshToken) throw new Error(`Missing YOUTUBE_REFRESH_TOKEN (run youtube-auth.mjs first to authorize; saved to ${APP_ENV})`);
+  const body = new URLSearchParams({ client_id: clientId, client_secret: clientSecret, refresh_token: refreshToken, grant_type: 'refresh_token' });
+  const res = await tfetch(TOKEN_ENDPOINT, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: body.toString() });
+  const json = await res.json().catch(() => ({}));
+  if (!res.ok || !json.access_token) {
+    // A revoked/expired refresh token surfaces here as invalid_grant — make it loud.
+    throw new Error(`Access-token refresh failed (${res.status}): ${json.error || ''} ${json.error_description || ''}${json.error === 'invalid_grant' ? ' — refresh token revoked/expired; re-run youtube-auth.mjs' : ''}`);
+  }
+  return json.access_token;
+}
+
+function buildMetadata({ title, description, tags, privacyStatus }) {
+  const tagArr = Array.isArray(tags) ? tags
+    : typeof tags === 'string' && tags.length ? tags.split(',').map((t) => t.trim()).filter(Boolean) : [];
+  return {
+    snippet: { title: title || '', description: description || '', tags: tagArr, categoryId: CATEGORY_ID },
+    status: { privacyStatus: privacyStatus || 'unlisted', selfDeclaredMadeForKids: false },
+  };
+}
+
+const resolveFile = (file) => { if (!file) throw new Error('file is required'); return isAbsolute(file) ? file : join(PROJECT_ROOT, file); };
+
+// Query how many bytes the resumable session has already received (0 on a fresh session).
+async function queryOffset(location, size, accessToken) {
+  const res = await tfetch(location, {
+    method: 'PUT',
+    headers: { Authorization: `Bearer ${accessToken}`, 'Content-Range': `bytes */${size}`, 'Content-Length': '0' },
+  }, T_SHORT);
+  if (res.status === 200 || res.status === 201) { const j = await res.json().catch(() => ({})); return { done: true, json: j }; }
+  if (res.status === 308) {
+    const range = res.headers.get('range'); // e.g. "bytes=0-262143"
+    const m = range && range.match(/bytes=0-(\d+)/);
+    return { done: false, offset: m ? Number(m[1]) + 1 : 0 };
+  }
+  return { done: false, offset: 0 }; // unknown → restart the bytes (still same session, no extra quota)
+}
+
+// Best-effort custom thumbnail (a failure here must NOT fail the upload).
+async function setThumbnail(videoId, file, accessToken) {
+  try {
+    const abs = resolveFile(file);
+    if (!existsSync(abs)) return { ok: false, note: 'thumb not found' };
+    const bytes = await readFile(abs);
+    const res = await tfetch(`${THUMBNAIL_ENDPOINT}?videoId=${videoId}`, {
+      method: 'POST',
+      headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'image/jpeg', 'Content-Length': String(bytes.length) },
+      body: bytes,
+    }, T_SHORT);
+    return { ok: res.ok, note: res.ok ? 'set' : `HTTP ${res.status}` };
+  } catch (e) { return { ok: false, note: e.message }; }
+}
+
+export async function uploadShort({ file, title, description, tags, privacyStatus = 'unlisted', thumbnail }) {
+  const abs = resolveFile(file);
+  if (!existsSync(abs)) throw new Error(`File not found: ${abs}`);
+  const size = statSync(abs).size;
+
+  const creds = loadCreds();
+  const accessToken = await refreshAccessToken(creds);
+  const metadata = buildMetadata({ title, description, tags, privacyStatus });
+
+  // Step 1 — init the resumable session (ONCE; never re-POSTed on retry → no quota waste).
+  const initRes = await tfetch(RESUMABLE_ENDPOINT, {
+    method: 'POST',
+    headers: {
+      Authorization: `Bearer ${accessToken}`,
+      'Content-Type': 'application/json; charset=UTF-8',
+      'X-Upload-Content-Type': 'video/*',
+      'X-Upload-Content-Length': String(size),
+    },
+    body: JSON.stringify(metadata),
+  }, T_SHORT);
+  if (!initRes.ok) { const t = await initRes.text().catch(() => ''); throw new Error(`Resumable init failed (${initRes.status}): ${t.slice(0, 400)}`); }
+  const location = initRes.headers.get('location');
+  if (!location) throw new Error('Resumable init returned no Location header');
+
+  // Step 2 — PUT the bytes, RESUMING from the server's offset on any failure.
+  const bytes = await readFile(abs);
+  let offset = 0;
+  let lastErr = null;
+  for (let attempt = 1; attempt <= PUT_ATTEMPTS; attempt++) {
+    try {
+      const slice = offset > 0 ? bytes.subarray(offset) : bytes;
+      const putRes = await tfetch(location, {
+        method: 'PUT',
+        headers: { 'Content-Type': 'video/*', 'Content-Length': String(size - offset), 'Content-Range': `bytes ${offset}-${size - 1}/${size}` },
+        body: slice,
+      }, T_PUT);
+      if (putRes.ok) {
+        const j = await putRes.json().catch(() => ({}));
+        if (j.id) return await finalize(j.id, thumbnail, accessToken);
+        throw new Error(`Byte upload OK but no video id: ${JSON.stringify(j).slice(0, 300)}`);
+      }
+      if (putRes.status === 308) { // incomplete — resume from reported offset
+        const range = putRes.headers.get('range');
+        const m = range && range.match(/bytes=0-(\d+)/);
+        offset = m ? Number(m[1]) + 1 : offset;
+        continue;
+      }
+      const t = await putRes.text().catch(() => '');
+      throw new Error(`Byte upload failed (${putRes.status}): ${t.slice(0, 300)}`);
+    } catch (e) {
+      lastErr = e;
+      if (attempt === PUT_ATTEMPTS) break;
+      await sleep(1500 * attempt); // backoff
+      const q = await queryOffset(location, size, accessToken).catch(() => ({ done: false, offset }));
+      if (q.done && q.json && q.json.id) return await finalize(q.json.id, thumbnail, accessToken);
+      offset = q.offset ?? offset;
+    }
+  }
+  throw new Error(`Byte upload failed after ${PUT_ATTEMPTS} attempts: ${lastErr && lastErr.message}`);
+}
+
+async function finalize(videoId, thumbnail, accessToken) {
+  let thumb = { ok: false, note: 'skipped' };
+  if (thumbnail) thumb = await setThumbnail(videoId, thumbnail, accessToken);
+  return { videoId, url: `https://youtu.be/${videoId}`, thumbnail: thumb };
+}
+
+// --- CLI -------------------------------------------------------------------
+function parseArgs(argv) {
+  const args = { dryRun: false };
+  for (let i = 2; i < argv.length; i++) {
+    const a = argv[i]; const next = () => argv[++i];
+    switch (a) {
+      case '--file': args.file = next(); break;
+      case '--title': args.title = next(); break;
+      case '--description': args.description = next(); break;
+      case '--tags': args.tags = next(); break;
+      case '--thumbnail': args.thumbnail = next(); break;
+      case '--privacy': case '--privacyStatus': args.privacyStatus = next(); break;
+      case '--dry-run': args.dryRun = true; break;
+      default: break;
+    }
+  }
+  return args;
+}
+
+async function cli() {
+  const args = parseArgs(process.argv);
+  if (!args.file) { console.error('Usage: node scripts/short/upload-youtube.mjs --file <path> --title "…" [--thumbnail p] [--privacy unlisted] [--dry-run]'); process.exit(1); }
+  const privacyStatus = args.privacyStatus || 'unlisted';
+  const metadata = buildMetadata({ title: args.title, description: args.description, tags: args.tags, privacyStatus });
+  const abs = resolveFile(args.file);
+  const exists = existsSync(abs);
+  const size = exists ? statSync(abs).size : 0;
+
+  if (args.dryRun) {
+    console.log('\n[upload-youtube] --dry-run — NO upload performed.\n');
+    console.log(`File: ${abs}  exists=${exists}${exists ? ` (${size} bytes)` : ' (WARNING: not found)'}`);
+    console.log(`Endpoints:\n  1. POST ${RESUMABLE_ENDPOINT}\n  2. PUT <Location> (resumable, Content-Range)\n  thumb: POST ${THUMBNAIL_ENDPOINT}?videoId=…\n  token: POST ${TOKEN_ENDPOINT}`);
+    console.log('\nMetadata:\n' + JSON.stringify(metadata, null, 2));
+    console.log(`\ncategoryId=${CATEGORY_ID}, selfDeclaredMadeForKids=false, privacyStatus=${privacyStatus}\n`);
+    return;
+  }
+  try {
+    const r = await uploadShort({ file: args.file, title: args.title, description: args.description, tags: args.tags, privacyStatus, thumbnail: args.thumbnail });
+    console.log(`\n[upload-youtube] Uploaded. videoId=${r.videoId}\n  ${r.url}\n  thumbnail: ${r.thumbnail.note}\n`);
+  } catch (e) { console.error(`\n[upload-youtube] FAILED: ${e.message}\n`); process.exit(1); }
+}
+
+// Run the CLI only when invoked directly (extension-agnostic — survives the .js→.mjs rename).
+if (process.argv[1] && process.argv[1] === fileURLToPath(import.meta.url)) cli();
diff --git a/scripts/short/youtube-auth.mjs b/scripts/short/youtube-auth.mjs
new file mode 100644
index 0000000..94d4892
--- /dev/null
+++ b/scripts/short/youtube-auth.mjs
@@ -0,0 +1,264 @@
+#!/usr/bin/env node
+// youtube-auth.js — OAuth 2.0 loopback flow for the allnewsdaily daily-Short pipeline (TK-11342, STAGE 4).
+// Node v26, raw fetch, NO googleapis dependency.
+//
+// Flow:
+//   1. Read YOUTUBE_CLIENT_ID / YOUTUBE_CLIENT_SECRET from ~/Projects/secrets-manager/.env
+//   2. Start a localhost server on http://localhost:9964/oauth2callback
+//   3. PRINT the consent URL for Steve to open
+//   4. Catch the ?code=, exchange for tokens
+//   5. SAVE refresh_token to ~/Projects/allnewsdaily/.env as YOUTUBE_REFRESH_TOKEN=
+//   6. channels.list?mine=true&part=snippet → PRINT the authorized channel title
+//   7. On redirect_uri_mismatch, print a clear fix instruction
+//
+// Flags:
+//   --print-url-only   Build + print the consent URL and exit (no server, for self-test)
+//
+// Usage: node scripts/short/youtube-auth.js   (Ctrl-C to abort the wait)
+
+import http from 'node:http';
+import { readFileSync, writeFileSync, existsSync } from 'node:fs';
+import { homedir } from 'node:os';
+import { join } from 'node:path';
+
+const SECRETS_ENV = join(homedir(), 'Projects', 'secrets-manager', '.env');
+const APP_ENV = join(homedir(), 'Projects', 'allnewsdaily', '.env');
+
+const PORT = 9964;
+const REDIRECT_URI = `http://localhost:${PORT}/oauth2callback`;
+const SCOPES = [
+  'https://www.googleapis.com/auth/youtube.upload',
+  'https://www.googleapis.com/auth/youtube.readonly',
+];
+
+const AUTH_ENDPOINT = 'https://accounts.google.com/o/oauth2/v2/auth';
+const TOKEN_ENDPOINT = 'https://oauth2.googleapis.com/token';
+const CHANNELS_ENDPOINT =
+  'https://www.googleapis.com/youtube/v3/channels?mine=true&part=snippet';
+
+// --- tiny .env parser (no dotenv dependency) -------------------------------
+function parseEnv(path) {
+  const out = {};
+  if (!existsSync(path)) return out;
+  const txt = readFileSync(path, 'utf8');
+  for (const raw of txt.split('\n')) {
+    const line = raw.trim();
+    if (!line || line.startsWith('#')) continue;
+    const eq = line.indexOf('=');
+    if (eq === -1) continue;
+    const key = line.slice(0, eq).trim();
+    let val = line.slice(eq + 1).trim();
+    if (
+      (val.startsWith('"') && val.endsWith('"')) ||
+      (val.startsWith("'") && val.endsWith("'"))
+    ) {
+      val = val.slice(1, -1);
+    }
+    out[key] = val;
+  }
+  return out;
+}
+
+// --- upsert a KEY=value into an .env file, preserving the rest -------------
+function upsertEnv(path, key, value) {
+  let lines = [];
+  if (existsSync(path)) {
+    lines = readFileSync(path, 'utf8').split('\n');
+  }
+  const idx = lines.findIndex((l) => l.trim().startsWith(`${key}=`));
+  const newLine = `${key}=${value}`;
+  if (idx >= 0) {
+    lines[idx] = newLine;
+  } else {
+    // keep a trailing newline tidy
+    if (lines.length && lines[lines.length - 1].trim() === '') {
+      lines.splice(lines.length - 1, 0, newLine);
+    } else {
+      lines.push(newLine);
+    }
+  }
+  let out = lines.join('\n');
+  if (!out.endsWith('\n')) out += '\n';
+  writeFileSync(path, out, { mode: 0o600 });
+}
+
+function loadCreds() {
+  const env = parseEnv(SECRETS_ENV);
+  const clientId = env.YOUTUBE_CLIENT_ID || process.env.YOUTUBE_CLIENT_ID;
+  const clientSecret =
+    env.YOUTUBE_CLIENT_SECRET || process.env.YOUTUBE_CLIENT_SECRET;
+  if (!clientId || !clientSecret) {
+    console.error(
+      `\n[youtube-auth] FATAL: YOUTUBE_CLIENT_ID / YOUTUBE_CLIENT_SECRET not found in ${SECRETS_ENV}\n` +
+        `Route them via the secrets skill first.\n`
+    );
+    process.exit(1);
+  }
+  return { clientId, clientSecret };
+}
+
+function buildConsentUrl(clientId) {
+  const p = new URLSearchParams({
+    client_id: clientId,
+    redirect_uri: REDIRECT_URI,
+    response_type: 'code',
+    scope: SCOPES.join(' '),
+    access_type: 'offline', // ask for a refresh_token
+    prompt: 'consent', // force a refresh_token even on re-auth
+    include_granted_scopes: 'true',
+  });
+  return `${AUTH_ENDPOINT}?${p.toString()}`;
+}
+
+async function exchangeCodeForTokens(code, clientId, clientSecret) {
+  const body = new URLSearchParams({
+    code,
+    client_id: clientId,
+    client_secret: clientSecret,
+    redirect_uri: REDIRECT_URI,
+    grant_type: 'authorization_code',
+  });
+  const res = await fetch(TOKEN_ENDPOINT, {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+    body: body.toString(),
+  });
+  const json = await res.json().catch(() => ({}));
+  if (!res.ok) {
+    if (json.error === 'redirect_uri_mismatch') {
+      console.error(
+        `\n[youtube-auth] redirect_uri_mismatch.\n` +
+          `The OAuth client is a "Web application" type and does NOT trust this redirect.\n` +
+          `FIX: In Google Cloud Console → APIs & Services → Credentials → your OAuth 2.0 Client ID,\n` +
+          `     add this EXACT authorized redirect URI:\n\n` +
+          `        ${REDIRECT_URI}\n\n` +
+          `     then re-run this script. (A "Desktop app" client type auto-trusts loopback and needs no registration.)\n`
+      );
+    } else {
+      console.error(
+        `\n[youtube-auth] Token exchange failed (${res.status}): ${json.error || ''} ${json.error_description || ''}\n`
+      );
+    }
+    throw new Error(json.error || `token exchange HTTP ${res.status}`);
+  }
+  return json; // { access_token, refresh_token, expires_in, scope, token_type }
+}
+
+async function getChannelTitle(accessToken) {
+  const res = await fetch(CHANNELS_ENDPOINT, {
+    headers: { Authorization: `Bearer ${accessToken}` },
+  });
+  const json = await res.json().catch(() => ({}));
+  if (!res.ok) {
+    console.error(
+      `[youtube-auth] channels.list failed (${res.status}): ${JSON.stringify(json).slice(0, 300)}`
+    );
+    return null;
+  }
+  const item = json.items && json.items[0];
+  return item ? item.snippet.title : null;
+}
+
+async function main() {
+  const printUrlOnly = process.argv.includes('--print-url-only');
+  const { clientId, clientSecret } = loadCreds();
+  const consentUrl = buildConsentUrl(clientId);
+
+  if (printUrlOnly) {
+    console.log('\n[youtube-auth] --print-url-only — consent URL:\n');
+    console.log(consentUrl + '\n');
+    console.log(
+      `redirect_uri : ${REDIRECT_URI}\n` +
+        `scopes       : ${SCOPES.join(' , ')}\n` +
+        `(no server started; exiting cleanly)\n`
+    );
+    return;
+  }
+
+  // Start the loopback server, then print the URL.
+  const server = http.createServer(async (req, res) => {
+    const url = new URL(req.url, `http://localhost:${PORT}`);
+    if (url.pathname !== '/oauth2callback') {
+      res.writeHead(404).end('Not found');
+      return;
+    }
+    const err = url.searchParams.get('error');
+    const code = url.searchParams.get('code');
+    if (err) {
+      res.writeHead(400, { 'Content-Type': 'text/plain' }).end(
+        `OAuth error: ${err}. You can close this tab.`
+      );
+      console.error(`\n[youtube-auth] Consent returned error: ${err}\n`);
+      server.close();
+      process.exit(1);
+    }
+    if (!code) {
+      res.writeHead(400).end('Missing ?code');
+      return;
+    }
+    try {
+      const tokens = await exchangeCodeForTokens(code, clientId, clientSecret);
+      if (!tokens.refresh_token) {
+        res.writeHead(200, { 'Content-Type': 'text/plain' }).end(
+          'Authorized, but Google returned NO refresh_token. Revoke access at ' +
+            'myaccount.google.com/permissions and re-run. You can close this tab.'
+        );
+        console.error(
+          '\n[youtube-auth] No refresh_token returned. Revoke the app at ' +
+            'https://myaccount.google.com/permissions and re-run (prompt=consent is set).\n'
+        );
+        server.close();
+        process.exit(1);
+      }
+      upsertEnv(APP_ENV, 'YOUTUBE_REFRESH_TOKEN', tokens.refresh_token);
+      const title = await getChannelTitle(tokens.access_token);
+      res.writeHead(200, { 'Content-Type': 'text/plain' }).end(
+        `Authorized${title ? ' as: ' + title : ''}. Refresh token saved. You can close this tab.`
+      );
+      console.log(`\n[youtube-auth] SUCCESS.`);
+      console.log(`  refresh_token saved → ${APP_ENV} (YOUTUBE_REFRESH_TOKEN=)`);
+      console.log(
+        `  NOTE: ${APP_ENV} is gitignored. Also route YOUTUBE_REFRESH_TOKEN via the \`secrets\` skill so it fans out to the registry.`
+      );
+      if (title) console.log(`  Authorized YouTube channel: "${title}"`);
+      else console.log(`  (channels.list returned no channel title)`);
+      server.close();
+      process.exit(0);
+    } catch (e) {
+      res.writeHead(500, { 'Content-Type': 'text/plain' }).end(
+        `Token exchange failed: ${e.message}. See terminal. You can close this tab.`
+      );
+      server.close();
+      process.exit(1);
+    }
+  });
+
+  server.listen(PORT, () => {
+    console.log(`\n[youtube-auth] Loopback server listening on ${REDIRECT_URI}`);
+    console.log(`\n>>> Open this URL in a browser and grant access:\n`);
+    console.log(consentUrl + '\n');
+    console.log('Waiting for the OAuth redirect… (Ctrl-C to abort)\n');
+  });
+
+  server.on('error', (e) => {
+    if (e.code === 'EADDRINUSE') {
+      console.error(
+        `\n[youtube-auth] Port ${PORT} is already in use. Close the other process and retry.\n`
+      );
+    } else {
+      console.error(`\n[youtube-auth] Server error: ${e.message}\n`);
+    }
+    process.exit(1);
+  });
+
+  process.on('SIGINT', () => {
+    console.log('\n[youtube-auth] Aborted (Ctrl-C). No tokens exchanged.');
+    server.close();
+    process.exit(0);
+  });
+}
+
+main().catch((e) => {
+  console.error(`[youtube-auth] Fatal: ${e.message}`);
+  process.exit(1);
+});

← 9a1fdc0 Live static-mode via sentinel file (no prod env/restart) ins  ·  back to Allnewsdaily  ·  allnewsdaily Short: auto-public + delete-canary (DTD verdict 42d9caf →