commit 70a0123f1e3c66f9d924e3f065dc6c1c6dc7b433
parent ef01fcd6406aa333ce294f1cfe5c236547664eb7
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Fri, 9 Oct 2026 13:08:56 -0400
Merge r19/integration into Track B (the release 19-21 plans, track C's doc fixes, the jobKinds test fix) before the Track B records
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
13 files changed, 504 insertions(+), 16 deletions(-)
diff --git a/PLAN.md b/PLAN.md
@@ -675,3 +675,19 @@ ollama pull qwen2.5:7b # ~4.7 GB Q4; llama3.1:8b is the alternative
The Claude Code lane needs the `claude` CLI installed and authenticated on the host, plus
`digest.remoteEnabled` turned on in settings.
+
+## Beyond the AI track: releases
+
+The phases above are the AI track. Later work is planned and recorded one release per file under `plans/`
+(releases 5–16: `plans/release-5.md` … `plans/release-16.md`). From release 17:
+
+| release | what | file | status (2026-10-09) |
+|---|---|---|---|
+| 17 | the media tier: a channel's big files on another drive, its text always on the corpus disk | [`plans/release-17.md`](plans/release-17.md) | complete, LIVE 2026-10-02 |
+| 18 | publishing as queueable stages: one index, per-site bundles, serial builds, deploys checked live, the publish lane | [`plans/release-18.md`](plans/release-18.md) | complete on `r18/integration` (`main` merged in, `4cffda3f`); fast-forward + rollout owed |
+| 19 | agents run the archive: ops/CLI/MCP (Track A), machine safety and tooling (Track B), OPERATING.md and the docs (Track C) | [`plans/release-19.md`](plans/release-19.md) | in flight |
+| 20 | the data model and the index: recorded dates, Twitch ids, the caption-track bug closed | [`plans/release-20.md`](plans/release-20.md) | planned, after 19 |
+| 21 | playable archives: local media attached to held videos (clips cut locally), per-video torrents played in the page, a home seeder of last resort behind a VPN; pilot TISM on jeralyzer-private | [`plans/release-21.md`](plans/release-21.md) | planned 2026-10-09 |
+| 22 | — | — | not yet planned |
+
+Work that landed on `main` without a plan of its own: [`plans/landed-2026-10.md`](plans/landed-2026-10.md).
diff --git a/README.md b/README.md
@@ -644,6 +644,12 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) to work on the code.
| [PUBLISH.md](PUBLISH.md) | Building and deploying sites: Pages + R2, cost-abuse protection, parallel builds in containers. |
| [SCHEDULED_SYNC.md](SCHEDULED_SYNC.md) | Unattended per-channel syncing. |
| [WORKTREES.md](WORKTREES.md) | Parallel checkouts and the port scheme. |
+| [SETTINGS.md](SETTINGS.md) | Every `settings.json` key (generated). |
+| [SITE.md](SITE.md) | Every `site.json` key (generated). |
+| [CHANNEL.md](CHANNEL.md) | Every key of a channel's `config.json` (generated). |
+| [REPORT.md](REPORT.md) | The cited report document, `report.json` (generated). |
+| [CITATIONS.md](CITATIONS.md) | The citation model every cited document shares (generated). |
+| [AGENTS.md](AGENTS.md) | Instructions for coding agents working in this repo. |
| [mcp/README.md](mcp/README.md) | The MCP server: tools, links, client setup. |
## License
diff --git a/common/jobs/jobKinds.test.ts b/common/jobs/jobKinds.test.ts
@@ -274,10 +274,12 @@ test("release 18: the kinds no longer created keep their labels (seven of them n
assert.equal(jobKindLabel("build-deploy-homepage"), "Build & deploy homepage");
});
-test("release 18: every drainable kind but the lane runners is an ingest kind", () => {
+test("release 18: every drainable kind but the lane runners and the clip-window batch is an ingest kind", () => {
const runners = new Set(["auto-transcribe", "auto-download", "auto-digest", "auto-backfill", "auto-publish"]);
+ // fetch-windows writes only `data/<id>/clips/`, a cache the index never reads.
+ const notIngest = new Set(["fetch-windows"]);
for (const kind of jobKindIds()) {
- if (!isDrainableKind(kind) || runners.has(kind)) continue;
+ if (!isDrainableKind(kind) || runners.has(kind) || notIngest.has(kind)) continue;
assert.equal(isIngestKind(kind), true, `${kind} is drainable and per channel: an ingest kind`);
}
for (const kind of runners) assert.equal(isIngestKind(kind), false, kind);
@@ -286,7 +288,7 @@ test("release 18: every drainable kind but the lane runners is an ingest kind",
assert.equal(isIngestKind(kind), true, kind);
}
// Nothing that publishes, moves or only reads.
- for (const kind of ["publish-build-site", "build-export", "relocate-channel-media", "scan-media", "fetch-window"]) {
+ for (const kind of ["publish-build-site", "build-export", "relocate-channel-media", "scan-media", "fetch-window", "fetch-windows"]) {
assert.equal(isIngestKind(kind), false, kind);
}
assert.equal(isIngestKind("no-such-kind"), false);
diff --git a/mcp/README.md b/mcp/README.md
@@ -15,6 +15,7 @@ clip window; the MCP itself still writes nothing.
| Tool | What it does |
|------|--------------|
| `list_channels` | List channels **organized under their channel groups** (name, slug, video count; site in hub mode), with a compact group cheat-sheet (`id · name · N channels`) for scoping. |
+| `list_tags` | The **curated tags** the archive publishes (the operator's cross-channel vocabulary, what `tags` on `search_transcripts` / `enumerate_matches` filters by): each tag's id, label, group, how many videos carry it on this source, and the per-channel breakdown. Not the yt-dlp keywords `scopes:["tags"]` searches. A source that publishes none says so. |
| `list_reports` / `get_report` | The cited reports a site publishes (corpus spec 5): the index (title, kind, claim and citation counts, a fact-check's verdict tally), then one report's sections and claims with every citation's **verbatim quote, original URL and the site's moment page**. See [Cited reports](#cited-reports). |
| `search_transcripts` | Search captions for a term/phrase (or regex); returns matching videos with timestamped snippets — **each `[mm:ss]` is a clickable link to that exact moment** (or a compact `[mm:ss\|sec]` with `link_style:"base"`). Alias-aware, pageable, and **filterable** (`states`, `date_from`/`date_to`, `media_type`, `age`, `exclude`, `scopes`). A page that isn't the whole match set is flagged **above** the hits. |
| `enumerate_matches` | A query's **complete** match set as a worklist (id/title/channel/date + batch count) in **one scan**. Takes the **same filters** as `search_transcripts`, so the two can never disagree about coverage. The tool to use whenever you need to count or cover everything. |
diff --git a/plans/README.md b/plans/README.md
@@ -2,7 +2,7 @@
The local-AI derived-corpus work spans many phases and months, and **context is cleared
between phases** to keep token cost down. That only works if a cold agent can resume without
-re-deriving anything. Five kinds of artifact, with deliberately different lifetimes:
+re-deriving anything. Seven kinds of artifact, with deliberately different lifetimes:
| File | Lifetime | Contents |
| --- | --- | --- |
@@ -10,7 +10,9 @@ re-deriving anything. Five kinds of artifact, with deliberately different lifeti
| [`FACTS.md`](FACTS.md) | Append-only | Verified codebase facts with `file:line` anchors. |
| [`STATE.md`](STATE.md) | Rewritten each session | Phase status, decisions log, open questions, commands known to pass. |
| `phase-N-<slug>.md` | Written at phase start, archived at merge | File-level detail for the phase in flight. |
-| `<topic>.md` design docs | Live until the design is fully shipped | A model that spans phases and outlives any one of them: [`unified-operations-model.md`](unified-operations-model.md) (how media-derived work is dispatched — the backend half) and [`editor-operations-ia.md`](editor-operations-ia.md) (what the editor's screens are about — the UI half, nine slices, slice 1 shipped). Read the matching one before touching a dispatcher or a page. |
+| `<topic>.md` design docs | Live until the design is fully shipped | A model that spans phases and outlives any one of them: [`unified-operations-model.md`](unified-operations-model.md) (how media-derived work is dispatched — the backend half) and [`editor-operations-ia.md`](editor-operations-ia.md) (what the editor's screens are about — the UI half, nine slices, complete). Read the matching one before touching a dispatcher or a page. |
+| `release-N.md` | Written at a release's start; its record grows per slice | One release: rulings, slices, the "as shipped" records, the rollout. Indexed at the end of `../PLAN.md`. |
+| `landed-<yyyy-mm>.md` | Written once | Work that reached `main` without a plan of its own: merges and what they landed. |
`FACTS.md` is the most valuable file here. It holds exactly the material that is expensive to
establish and cheap to get wrong — the original draft of this plan contained several
diff --git a/plans/STATE.md b/plans/STATE.md
@@ -3,7 +3,27 @@
The working memory for the local-AI derived-corpus work. Rewritten at the end of every
session, before context is cleared. See [`README.md`](README.md) for the protocol.
-**Now (2026-10-06): release 18 — publishing as queueable stages — is complete on `r18/integration`** (record:
+**Now (2026-10-09): release 18 is complete on `r18/integration`, and `main` is merged into it (`4cffda3f`, 94
+commits ahead of `main` `e921f82f`, 0 behind: a fast-forward).** The merge brought main's ~70 commits since 2026-10-06
+(umtool articles + notes, `ops transcribe` with word timings, fetch-windows, metadata refresh, channel
+create/rename/delete over ops; record: [`landed-2026-10.md`](landed-2026-10.md)); the changelogs keep both sides, r18
+first. Nothing of release 18 is live.
+- **Owed, in order:** fast-forward `main` to `r18/integration` from a session that is not worktree-isolated (the
+ primary clean first); the rollout per `~/reports/release-18/RUNBOOK.html` (editor rebuild + restart, Publish now,
+ jeralyzer preview, hub tombstones, step 5b `r18-eva-sites.sh` + production deploys of hasanalyzer and bonnellyzer,
+ policies, the lane on; the script guard sha updated to the merge); then pruning — the five `r18-*` slice worktrees
+ and their branches, `render-local-sources`; the ~170 merged branches and the stale worktrees (`diet-series`,
+ `export-gumroad-tip`, `r13-phase-5`) are listed for the operator to approve, never deleted unasked.
+- **The five working sessions** (Super Hasanalyzer, Super Jeralyzer, Super Jasolyzer, candace owens corpus analysis,
+ unified media aggregation) are doing corpus and content work and hold no unmerged code. The tooling gaps they
+ reported are releases 19–20.
+- **Releases 19–20 are in flight on three tracks** (index: `PLAN.md`, "Beyond the AI track: releases"): Track A
+ [`release-19.md`](release-19.md) A1–A9 (agents run the archive: ops, CLI, MCP) then
+ [`release-20.md`](release-20.md) (data model); Track B release 19 B1–B6 (machine safety and tooling); Track C docs
+ and plans (OPERATING.md and `archilyzer docs cli`, doc fixes, homepage docs, FACTS). Integration branch
+ `r19/integration`, branched from `4cffda3f`.
+
+**Previously (2026-10-06): release 18 — publishing as queueable stages — is complete on `r18/integration`** (record:
[`release-18.md`](release-18.md): slices S1 the stage contract, stamps, lock, bundles and CLI; S2 deploy hardening; S3
the status, the queue and the publish lane; S4 the surfaces; S5 the image half; S6 the records — each reviewed, merged
`--no-ff`; `main` `edadc712` merged in first). **Not on `main` yet, and nothing of it is live.**
@@ -102,8 +122,9 @@ written), [`release-14.md`](release-14.md), [`release-15.md`](release-15.md). Th
- The JSX entity/whitespace hazard sweep: 22 texts in 19 files run a word into the element before
them (FACTS, "Use with AI is the homepage's AI and MCP doc"; release 16 DX).
-**Now (2026-09-28, night): the stats cache key fix — built, reviewed (SHIP AFTER FIXES, then SHIP
-on re-review; every touch-up done), not merged.** The branch is `fix/stats-cache-key`, and [`stats-cache-key.md`](stats-cache-key.md)
+**Previously (2026-09-28, night): the stats cache key fix — built, reviewed (SHIP AFTER FIXES, then SHIP
+on re-review; every touch-up done), not merged.** (Merged `10cefd15` the same evening; the recount rolled out with
+release 15, the homepage at 77,547 transcripts on 2026-09-30.) The branch is `fix/stats-cache-key`, and [`stats-cache-key.md`](stats-cache-key.md)
holds the record, the review and the rollout. FACTS has "The stats cache key".
- **What it fixes:** the homepage showed Jasolyzer as 0 transcripts, 0 channels, 0 hours while it
served 1,889 videos. Instance-wide it showed 49,798 transcripts of about 77,000.
@@ -127,7 +148,7 @@ holds the record, the review and the rollout. FACTS has "The stats cache key".
and rolled out, the rollout's step 3 `Diff:` check stays the safeguard: thousands removed means
a drive was missing.
-**Now (2026-09-28, evening): release 12 — the source mirror — is merged to `main` and NOT rolled
+**Previously (2026-09-28, evening): release 12 — the source mirror — is merged to `main` and NOT rolled
out.** [`release-12.md`](release-12.md) holds Q's and R's records, their reviews, "Merged" and
"Rollout". The operator's runbook is `~/reports/release-12/RUNBOOK.html`, with its scripts in
`~/reports/release-12/scripts/`. The plan is [`source-mirror.md`](source-mirror.md).
@@ -164,7 +185,7 @@ out.** [`release-12.md`](release-12.md) holds Q's and R's records, their reviews
- **Baselines now:** common **2,149**, homepage unit **7**, homepage e2e **36**; editor unit 85,
`test:scripts` 185 + 1 and mcp 269 are unchanged.
-**Now (2026-09-28, afternoon): release 11 is LIVE as 0.10.0, and Jasolyzer is launched.** The
+**Previously (2026-09-28, afternoon): release 11 is LIVE as 0.10.0, and Jasolyzer is launched.** The
rollout ran 12:11–14:05 ([`release-11.md`](release-11.md), "Rollout, as done", every deploy and job
id): the cut (editor `24c8352e`, export `bf6904e8`), ONE :3001 restart (`BUILD_ID`
`vWCJb87ktCy5akih_pM9X`, on `main` `e6c5d2e3`), a Settings save (two ran; the second byte-identical — `buildPipeline.mode` dropped, the
@@ -187,7 +208,7 @@ hub and the homepage. **The LM chat-only tier is closed as moot** (Legal Mindset
Phase 5 (P0, the plan doc `one-core-phase-5.md`, awaits the operator's OK and five rulings).
- **Worktrees:** the seven `r11-*` stay until release 12's are gone (index-based ports).
-**Now (2026-09-28, morning): release 11 is merged to `main` in full and NOT rolled out.** The
+**Previously (2026-09-28, morning): release 11 is merged to `main` in full and NOT rolled out.** The
overnight of 2026-09-28 ([`release-11.md`](release-11.md): every slice's record, then "Integration,
as merged", then "Rollout"). `main` = `eb28a341` + three integration `plans:` commits
(FACTS `eeff74c3`, `brand-and-themes.md` `3643c974`, then this STATE and the record); no code
@@ -334,7 +355,7 @@ the history, newest first from "Accent swap and mark contrast rolled out".
- re-uploading the YouTube picture, watermark and banner (`~/reports/archilyzer-media/brand/`).
- **Next:** release 11, the overnight of 2026-09-28 ([`release-11.md`](release-11.md)).
-**Now (2026-09-26, afternoon): release 10 is merged to `main` in full and NOT rolled out.** It is
+**Previously (2026-09-26, afternoon): release 10 is merged to `main` in full and NOT rolled out.** It is
the brand (S0–S3: the Found-line mark and base × accent reader themes; its own final commit
`bb6f378f`, then cut as editor + export 0.9.0 at 01:37, `0e0d8f59` + `31798769`), plus brand S4 and
the lows L1 + L2. Those three merged 2026-09-26 12:52 on the operator's word, before the rollout,
diff --git a/plans/editor-operations-ia.md b/plans/editor-operations-ia.md
@@ -1,6 +1,9 @@
# The editor: one noun, not a pile
-**Status: the model is decided; slices 1, 2 and 3 have shipped.** This is the UI half of
+**Status: COMPLETE (verified against the tree 2026-10-09).** Slices 1–8 shipped (records below). Slice 9 shipped
+as one-core Phase 1 slice 1.3 (`253826b` → `451c454`, 2026-09-07; record `one-core-phase-1.md`): `digestSweep.ts`,
+`backfillSweep.ts`, `arbiter.ts`, `SweepLane.tsx` and `SweepScope.tsx` are gone, the sweep resume hooks left
+`instrumentation.ts`, and each lane has one console. This is the UI half of
[`unified-operations-model.md`](unified-operations-model.md), which states the backend half
("four schedulers for what is really one kind of work"; steps 1, 5, 6 open). Read that one
first if you are touching a dispatcher; read this one if you are touching a page.
diff --git a/plans/landed-2026-10.md b/plans/landed-2026-10.md
@@ -0,0 +1,91 @@
+# Landed without a plan — `main`, 2026-10-05 → 2026-10-09
+
+`main`'s first-parent history `39abec18..e921f82f`: 70 entries, 246 commits. Merged into `r18/integration` at `f2fd11a7`
+(through `edadc712`), `ff9c6b7d` (through `f6137a04`) and `4cffda3f` (the rest). The report-site slices have their own record,
+[`report-sites.md`](report-sites.md); everything else here had none. Each row is the merge and what it landed.
+FACTS has the verified details for the starred rows ("Verified 2026-10-09").
+
+## Report sites (recorded in `report-sites.md`)
+
+| merge | what |
+|---|---|
+| `6c140bc1` … `b94742c7` | R0–R8 + R4b: the citation model and report document, site keys, evidence media, compose, pages, consumers, converters, the editor's Reports tab, the cited-site e2e |
+| `89d65ae0` | `site.json` `search` switch replaces `publish`; search off = report-only; `export start` serves moment dirs |
+| `30447593` | per-report revision history (a bare git repo per report, history page + `history.json`, dumb-HTTP clone) |
+| `a1a98c2c` | per-report HTML, PDF, Markdown and evidence-pack exports; `reports-export` job; shared publish size limit |
+
+## Reports and citations
+
+| merge | what |
+|---|---|
+| `32c49f45` | a site's report ids use the report document's id rule |
+| `bf5050b7` | a report claim may carry a short title; a source may carry a note |
+| `7473e3bb` | a report quote is checked against every transcript of the record; its moment shows the one that matched |
+| `a01b9f63` | export: one report = the home page; the index without a duplicate title |
+| `4f6ee08a` | report series tag; no index of one; lean index cards |
+| `53014ad1` | report polish: claim flag, series line, byline + attribution, source rail, citation origin, three reading tiers |
+| `b26eaff0`, `f986152f` | report header: our heading, one date/revision line, the reviewed document as a citation card |
+| `4fa5c46b`, `694217b9` | evidence clips stop at the recording's end; prepare and compose share the clamped cited span |
+| `f6137a04` | a `#<id>` link in report markdown stays in the tab |
+| `125163ab` | a report can carry a video |
+| `e99f0e4f` | a cite label may hold an editorial `[insertion]` |
+| `7f688cf9` | export: `serve-out` binds `HOST` when set |
+
+## Sources and posts
+
+| merge | what |
+|---|---|
+| `9cb6bf99` | a metadata-scan members-only or private verdict keeps a video out of the auto-download queue |
+| `96812e72` | `archilyzer feeds backfill-metadata`: a podcast feed's titles, dates and descriptions onto feed-imported records through a history-recorded writer; job kind + ops route |
+| `21cc5741` | XenForo threads as a posts source: paced fetch, saved-page import, forum session Connect, post capture |
+| `ea3bc5cc` | forum mirror hosts |
+| `c85b7155` | archive.org items and files as a video/audio source: polite import, provenance, file player, archive.org + torrent links on citations |
+| `dc6a3c3c` | archive.org files over BitTorrent via aria2c, seeded; verified direct-download fallback; no yt-dlp for archive.org |
+| `5f88bfee`, `c9fcdc80` | archive.org file records: clean titles, dates from the file name or folder; `archive-org refresh` |
+| `b8466d24` | BitChute as a platform: detection, polite import and pacing, native player, relabel pass |
+| `33db0dd4` | Wayback captures named by what they copy; `wayback.json` provenance; `wayback refresh`; archived-copy links |
+| `edadc712` | a one-off Odysee or BitChute import waits a floor after the last one there |
+| `0adfcb23` | a renamed social channel's posts read as their new slug |
+| `dafdd904` | an older X walk can start at a date; Kiwi Farms player uploads are video media |
+| `283dce07` | forum capture downloads the media a post's page holds now |
+
+## Transcripts
+
+| merge | what |
+|---|---|
+| `200a3105` | one caption-track rule: en-orig first, empty tracks fall through; cue-block VTTs parse; a one-shot index re-read |
+| `e506f738` | every English track searchable; the reader and MCP choose a track; alternates only where words differ |
+| `4f1d647f` ★ | `pnpm ops transcribe` / `POST /api/ops/transcribe`: one local file through the editor's workers (`3524be34`, `6814ae6f`) |
+| `772ae618` ★ | `"words": true` returns the engine's word timings (`6d94995f`) |
+
+## Editor and ops
+
+| merge | what |
+|---|---|
+| `41029f40` ★ | refresh one video's metadata from the video page and over ops; refuses a video with no `data/<id>/` (`26dad6a0`, `9c1df123`) |
+| `9e1e8cc2` ★ | rename and delete a social channel from its page; channel create/rename/delete/sites over ops (`b16653af`) |
+| `0c9fbe9d` | Rumble clip windows retry with `-extension_picky 0` |
+| `de11b507` ★ | fetch-windows: a paced batch of clip windows, one job per platform; the /jobs row names who asked, for what, how many (`414a7bb2`, `4e404ff9`, `103d81af`) |
+| `f011109d`, `1a17d841` | a window's own URL picks its queue and its platform args |
+| `a2448b11` | one Rumble page load per window |
+| `903a4d0a` | Rumble windows 120 s apart, across runs |
+| `20ad060a` | page titles lose their lead paragraphs; /channels' header wraps |
+| `19b03898` | e2e: export-search's filter checkboxes located by exact name |
+
+## umtool and report-to-video
+
+| merge | what |
+|---|---|
+| `e2c6eb14` | report-to-video assembles a long cut in crossfaded batches, then joins them |
+| `ddaef3f0` | deck: posts ride the whole clip, `shotMaxHeight`, web platform, per-post variant; `snapLead`/`snapTail` |
+| `8cf1201f` | post cards: accent, logo, flag |
+| `de8eebab` | umtool takes: alternative renders of part of a cut, side by side, judged |
+| `fac257cf`, `3d09ee71`, `526b2b22` | teaser rows that change in place (per-line role and hold); a second tier pops with its line; a small tier above |
+| `f294d107` | thread rail, flips panel, panning screenshots, the date always shows |
+| `38c11e41` | the renderer runs without a display |
+| `dc35dfd7` | dim dots ahead; outcomes wait for the rail |
+| `e921f82f` ★ | umtool articles + notes: `/sites` (every site's articles, site pages, media, workspace files); the notes store (`notes.json` beside a report, never published, exported or committed to history), `/api/notes`, `umtool notes` CLI + agent digest; timed, take and edit notes; the timeline and structure editors; kinds declared on the registry (`d319982f` … `847b8188`, 20 commits) |
+
+## Plans commits
+
+`1af693a4`, `86e49763`, `53c8d776`, `771a55be` — the report-sites plan and its record.
diff --git a/plans/one-core.md b/plans/one-core.md
@@ -317,7 +317,7 @@ https://jeralyzer.pages.dev/corpus.json` before and after, byte-identical output
> `config.json`, sidecars) shipped 2026-09-24 (`3241fed2`, `ef88ac4d`). Release 4 shipped the
> same evening: slice P (/channels rack polish, `77a32de2`), slice W (one write idiom,
> `ddad13f4`) and slice 3b (the `VideoPanel` split, `e172749b`). Release 4 is not yet live
-> on :3001. Phase 4 is next.
+> on :3001. Phase 4 is next (done 2026-09-28, below).
> Records, gates and what is still open: [`one-core-phase-3.md`](one-core-phase-3.md).
1. **`common/views/`**: move `buildActiveJobs`, `buildWorkers`, `lanes.ts`,
@@ -459,7 +459,12 @@ counters and the private tmp + rename code at 26 write sites.*
### Phase 4 — CLI, entry points, config, docs (3 slices)
-> **Next** (after the exports-off and Rumble releases, see
+> **DONE 2026-09-28** (release 11): slice 3 checkpoints A (`cb9d02b2`: `archilyzer doctor`, `run`, `mcp`, every bin a
+> subcommand, generated `ENVIRONMENT.md`, `PUBLISH.md` absorbing the deploy docs) and B (`eb28a341`: test-only env vars
+> `E2E_`-prefixed, the harnesses read `ports.mjs`); the build-mode stub dropped (O6c). The optional SETUP/PLAN.md
+> consolidations were not done. Record: `release-11.md`, STATE "release 11".
+>
+> *Was:* **Next** (after the exports-off and Rumble releases, see
> [`one-core-phase-3.md`](one-core-phase-3.md#next--phase-4)). Starting points, checked on
> `e172749b`:
> - `editor/app/sites/lib/buildDeployCore.ts` is 578 lines and imports nothing from
diff --git a/plans/release-19.md b/plans/release-19.md
@@ -0,0 +1,123 @@
+# Release 19 — agents run the archive (ops, CLI and MCP; machine safety; the operator's guide)
+
+Written 2026-10-09 from the "next batch" plan (releases 19–22), against `r18/integration` `4cffda3f` (release 18
+complete, `main` `e921f82f` merged in). Rules: `plans/tools/implementer-rules.md` — three parallel Opus implementers,
+one per track, each in its own worktree branched from `4cffda3f`; the parent merges each track into `r19/integration`
+`--no-ff` on a clean tree, then fast-forwards `main`; records state rulings never reasons; counts-only privacy greps
+before every merge; implementers' commits carry their OWN model's `Co-Authored-By` + the session line; never boot a
+second editor against the real corpus; the homepage build gate is `build:nodata`; memory gate (`free -m` available
+≥ 6 GB) before any build, render or e2e, every `next build` under a 5 GB `systemd-run` scope. Record file: this file
+(shape of `plans/release-18.md`). Release 20 (the data model) is [`release-20.md`](release-20.md).
+
+## Context
+
+- Five working sessions (Super Hasanalyzer, Super Jeralyzer, Super Jasolyzer, candace owens corpus analysis, unified
+ media aggregation) do corpus and content work and hold no unmerged code. Every gap they report is repo-wide
+ tooling: wrappers that find `WORKER_TOKEN`, `joblog.sh`/`watchjobs.sh` job watchers, hand-run archive.org imports,
+ hard-linking another site's `export/out` aside, two OOMs from concurrent heavy work.
+- Precedents to reuse: `editor/app/api/ops/_lib.ts` (`ops`, `opsAuth`, field readers, `jobResponse`), route unit
+ tests (`editor/app/api/ops/{fetch-windows,transcribe,rename-channel}/route.test.ts`), `scripts/archilyzer-ops.mjs`
+ (`ACTIONS`, `GETTERS`, `--wait`), the views (`common/views/names.ts`, `/api/view/*`), `mcp/src/fetchClip.ts`.
+- Tests: the editor e2e is 130 specs / ~709 tests / ~40 min, serial, behind a machine-wide lock; the editor has 29
+ unit-test files. No CI, no root `test` / `typecheck`; the editor's `lint` has no config.
+- **Precondition (Wave 0, release 18's rollout):** `main` fast-forwarded to `r18/integration`, the release 18 rollout
+ run, the `r18-*` worktrees pruned. Every `editor/app/api/ops` or `archilyzer-ops.mjs` change merges after it.
+
+## Rulings (operator, 2026-10-09; do not re-open)
+
+- **The MCP gains archival writes through the editor** — the `fetch_clip` pattern: the MCP server asks the editor
+ (editor URL + `WORKER_TOKEN`), the editor writes. Settings, storage and deletes stay CLI/ops-only.
+- **Three parallel tracks**: A owns the e2e slot (code over ops/CLI/MCP, sequential), B is machine safety and tooling
+ (no editor e2e; one umtool run), C is docs and plans (no e2e).
+- **Release 18 lands first**, by a session that may touch the primary checkout.
+- **e2e budget:** one editor/umtool/export e2e run at a time machine-wide; the full editor suite only at a release's
+ end. Every slice carries its class: `[none]` docs/plans only, `[unit]` unit tests are its gate, `[spec]` one or
+ two named specs, `[suite]` the full suite.
+- Polite scraping applies to every import and listing: one stream, real gaps, back off on 429.
+
+## Slices
+
+### Track A — release 19 (owner: the Track A implementer; sequential; owns the e2e slot)
+
+| slice | class | what | after |
+|---|---|---|---|
+| **A1** `pnpm ops` finds its token | `[unit]` | read `WORKER_TOKEN` / `ARCHILYZER_EDITOR_URL` from `editor/.env` when unset; `--wait` polls a real authed route instead of the `/api/jobs/active` rewrite | Wave 0 |
+| **A2** jobs over ops | `[unit]` + `[spec ops-api]` | `get jobs [--active\|--failed\|--kind\|--slug]`, `get job <id> [--tail]`, `job cancel\|retry\|retry-failed\|drain\|promote\|force-release <id…>`, `--wait` on many ids with queue position; wraps `editor/app/jobs/actions.ts` | A1 |
+| **A3** read side | `[unit]` | `get settings\|storage\|sites\|workers\|auto-queue\|scheduler\|cleanup <slug>` over the existing view builders/readers; `/api/auto-queue/control` gated behind `opsAuth` (unauthenticated today) | A2 |
+| **A4** archival writes | `[unit]` | `settings` patch (through `saveSettings` + the schema); `lane` start/stop/drain for every lane incl. `publish`; `clear-platform-hold`; `workers enable\|disable`; per-video `transcribe-one\|delete-file\|do-not-clean`; cleanup buckets; `relocate {dryRun}`; `archilyzer storage report` (tierable bytes per channel) | A3 |
+| **A5** fetch queue hygiene | `[unit]` | the editor dedupes identical fetch-window requests (same slug/id/window → the existing job); `fetch-via-editor.mjs` waits without a timeout, printing queue position; small fetch-window jobs get their own queue key / priority instead of waiting behind a multi-hour `persist-videos` | A4 |
+| **A6** imports | `[unit]` | `import-archive-org {items:[…] \| query}` with the polite inter-item gap, a `dryRun` that flags restricted (401) items, skip-if-held; `get remote-listing <slug>` for Odysee/BitChute (paced, one stream) diffed against held videos | A5 |
+| **A7** cues without an index | `[unit]` | `pnpm ops build-cues --slug [--ids]` (normalize → `transcript.cues.json`); MCP `get_transcript` falls back to the VTT | A6 |
+| **A8** publish edges | `[unit]` | `POST publish {verb:"build"}` takes `allowMissingMedia`; `pnpm ops lane` covers `publish`; `archilyzer publish build <id> --out <dir>` for a private build served elsewhere (cheap on release 18's bundles) | A7 |
+| **A9** MCP archival writes | `[unit]` | on the `fetch_clip` pattern: `get_job` (status + log tail), `enqueue` for sync / download-missing ids / transcribe-bucket / fetch-posts / import-video (each → the existing ops route), `channel_coverage` (held videos by date, gaps), notes `list\|read\|reply` (umtool `/api/notes`, `UMTOOL_URL`); instructions in `mcp/src/instructions.ts`; the README tool table | A8 |
+| release end | `[spec ops-api]` + `[suite]` | one `ops-api.spec` run, then the full editor suite once | A1–A9 |
+
+### Track B — release 19 (owner: the Track B implementer; no editor e2e)
+
+| slice | class | what | after |
+|---|---|---|---|
+| **B1** heavy-work gate | `[unit]` | `scripts/queue-lock.mjs` generalized into `pnpm heavy -- <cmd>`: one heavy slot machine-wide + a ≥ 6 GB free-memory floor; used by e2e, `next build` (publish stages, `build-site.sh`), `build-video.mjs` renders; a render may hold the transcription lane for its duration (ops `lane`); documented in WORKTREES.md + AGENTS.md | — |
+| **B2** report-to-video robustness | `[unit]` | prune `*-frames` after the final mux (`--keep-frames` keeps them); `--chrome-only` builds missing segments; a manifest lint before render (teaser > 34 chars, image src relative to the manifest, a QR legible at 720p). `umtool/report-to-video/` is the Candace session's: coordinated with it | B1 |
+| **B3** report CLI | `[unit]` | `archilyzer reports check <site> [--reports …]` (compose without a build); `reports verify-quotes <report.json>` (quote vs cue span, the en-orig check); `reports attach-video` encoding to fit the 24 MiB compose limit | — |
+| **B4** ops transcribe | `[unit]` | `durationMs` excludes queue wait; a priority for short one-file jobs over long auto jobs | Wave 0 |
+| **B5** umtool small debts | `[unit]` → `[spec umtool]` | per-worktree umtool e2e ports (`portFor`); `mix.spec` order dependence; `umtool window` writes edit notes; a selection spanning two blocks gets a Note button; the CLI finds `SITES_DIR` from the repo, not the cwd; umtool's /sites heading becomes "Articles" (naming hazard vs the editor's /sites). ONE umtool e2e run | B1 |
+| **B6** test economy | `[unit]` | pure/API e2e moves to unit: `audio-check-classifier.spec` → common; `ops-api.spec` routes → `route.test.ts`; `view-route`, `worker-unit`, `media-file-abort`. Root `pnpm test` and `pnpm typecheck`; the editor gets an eslint config or loses its broken `lint` script | — |
+
+### Track C — docs and plans (owner: the Track C implementer; no e2e)
+
+| slice | class | what |
+|---|---|---|
+| **C1** plans hygiene | `[none]` | STATE "Now"; PLAN.md's release index; this file and `release-20.md`; the "landed without a plan" record ([`landed-2026-10.md`](landed-2026-10.md)); one-core Phase 4, `editor-operations-ia.md`, `stats-cache-key.md` statuses |
+| **C2** OPERATING.md + `archilyzer docs cli [--check]` | `[unit]` | recipes (add a channel; sync and download; import from archive.org / Odysee / BitChute / Wayback; fetch and capture posts; transcribe; tag; prepare and export reports; publish) over `pnpm ops`, `archilyzer` and the MCP; a generated command reference from `common/bin/_cli.ts` `usage()` and `scripts/archilyzer-ops.mjs` `usage()` on the `env-docs.ts` / `file-schemas-docs.ts` pattern; linked from AGENTS.md, README, CONTRIBUTING (its hand-picked commands replaced by the link). Regenerated after Track A's actions land |
+| **C3** doc fixes | `[none]` | `mcp/README.md` lists every tool; README's docs table adds AGENTS, SETTINGS, SITE, CHANNEL, REPORT, CITATIONS; RUNNING_IN_DOCKER's ops list points at the generated reference |
+| **C4** homepage docs | `[unit]` | `homepage/content/docs/operate.md` gains the CLI/ops/MCP overview; gates `build:nodata` + `pnpm --filter homepage test` |
+| **C5** FACTS refresh | `[none]` | verified facts for main's last ~70 commits (notes store, articles kind registry, ops transcribe word timings, fetch-windows pacing, metadata refresh) |
+
+### File ownership
+
+| track | owns |
+|---|---|
+| A | `editor/app/api/ops/**` (except B4's transcribe route), `scripts/archilyzer-ops.mjs` + its test, `mcp/src/**`, `editor/app/jobs/actions.ts`, `common/controller/fetchWindows.ts`, `fetch-via-editor.mjs` |
+| B | `scripts/queue-lock.mjs` and the heavy gate, `umtool/**` (except `umtool/report-to-video/*`, the Candace session's), the e2e specs B6 moves, the report CLI, `common/controller/transcribeFile.ts`, `editor/app/api/ops/transcribe/**` |
+| C | `plans/**` (except the records others append), root `*.md` docs, `homepage/content/docs/**`, `mcp/README.md`, the `archilyzer docs cli` generator |
+| shared, append-only | `editor/CHANGELOG.md`, `AGENTS.md`, `common/bin/_cli.ts` (rows only) |
+
+### Graph and merge order
+
+```
+Wave 0 (release 18 lands) ──► A1 ──► A2 ──► … ──► A9 ──► release-end suite (Track A is sequential: A1–A5, then A6–A9)
+ └─► B4
+now: B1 ──► {B2, B5}; B3; B6 (B6 touches no release-18 file)
+now: C1, C3 ──► C2 ──► C4, C5; C2 regenerated after A's actions land
+```
+
+- **Merge order:** Wave 0 before any `editor/app/api/ops` or `archilyzer-ops.mjs` change (they conflict with release
+ 18). C1, C3 and B6 touch no release-18 file and may merge first. Track A merges slice by slice in its own order;
+ B and C merge whenever a slice is green. The parent regenerates the command reference (C2) after each Track A merge
+ that adds an ops action or CLI row.
+- **Do not touch:** `umtool/report-to-video/*` without the Candace session; `transcripts/**` except through the
+ writers; the working sessions' `~/reports/*` workspaces.
+
+## Verification
+
+- Every Track A slice: route unit tests (the `route.test.ts` pattern) + `scripts/archilyzer-ops.test.mjs`; MCP tools
+ via `pnpm --filter yt-dlp-transcript-mcp test` and one live `/ask`-style session against the editor; one
+ `ops-api.spec` run per release; the full editor suite once at release end.
+- B1: a unit test of the lock + a manual two-build collision showing the second waits. B6: the moved tests pass and
+ the editor suite shrinks by the moved count. B5: one umtool e2e run.
+- C2: `archilyzer docs cli --check` in the gates beside `docs env --check` and `docs files --check`; every command a
+ recipe names exists (grepped against the generated reference). C4: homepage `build:nodata` + unit.
+- Every track: `pnpm -r --no-bail --workspace-concurrency=1 exec tsc --noEmit` clean per commit; counts-only privacy
+ greps (home paths in added lines 0, no `transcripts/` content).
+
+## Record
+
+(Each slice adds a "#### Slice <X>, as shipped" section under its track, in merge order.)
+
+### Track A
+
+### Track B
+
+### Track C
+
+## Rollout
diff --git a/plans/release-20.md b/plans/release-20.md
@@ -0,0 +1,38 @@
+# Release 20 — the data model and the index (recorded dates, Twitch ids, the caption-track bug closed)
+
+Written 2026-10-09 from the "next batch" plan (releases 19–22), against `r18/integration` `4cffda3f`. Track A's, after
+release 19 ([`release-19.md`](release-19.md)); small. Rules: `plans/tools/implementer-rules.md` and release 19's
+rules line, unchanged. Record file: this file (shape of `plans/release-18.md`).
+
+## Context
+
+- A VOD-mirror channel's videos are dated by their upload to the mirror, not by the stream they copy; coverage
+ ("held videos by date, gaps", release 19 A9 `channel_coverage`) reads the wrong date for them.
+- A Twitch video's directory is `<n>` while its record carries `v<n>`; readers that join the two miss.
+- The `en` track → 0 cues bug (an `en` VTT that parses to 0 cues preferred over `en-orig`) was filed 2026-10-01.
+ Main's caption-track merges since (`200a3105` transcripts/en-track-fallback: en-orig first, empty tracks fall
+ through, cue-block VTTs parse, a one-shot index re-read; `e506f738` transcripts/multi-track) are the candidate fix.
+- `plans/stats-cache-key.md`: merged `10cefd15` (2026-09-28); the recount rolled out with release 15 (verified by
+ release 19 C1).
+
+## Slices
+
+| slice | class | what | after |
+|---|---|---|---|
+| **D1** recorded dates | `[unit]` | `recordedDate` for VOD-mirror channels, derived from the title by a per-channel rule; coverage reads it before the upload date | release 19 |
+| **D2** Twitch ids | `[unit]` | one normalization of the Twitch id (`<n>` directory vs `v<n>` record) at the index boundary | D1 |
+| **D3** the caption-track bug | `[unit]` + `[spec]` for the index | verify against the tree and a fixture that the `en` → 0 cues case is closed by `200a3105`; close it in STATE and FACTS, or fix what remains | — |
+
+**Owner:** the Track A implementer. **Merge order:** D3 may go first (verification); D1 → D2. Release end: the index
+spec(s) D3 names, then the full editor suite once if any editor code changed.
+
+## Verification
+
+- Unit tests per slice; the index spec for D3; tsc clean per commit; `docs files --check` if a schema changes.
+- Counts-only privacy greps before each merge.
+
+## Record
+
+(Each slice adds a "### Slice <X>, as shipped" section here, before "## Rollout".)
+
+## Rollout
diff --git a/plans/release-21.md b/plans/release-21.md
@@ -0,0 +1,180 @@
+# Release 21 — playable archives: local media for held videos, WebTorrent playback on the sites
+
+Written 2026-10-09. Pilot: the TISM (The Incredible Salt Mine) v1 torrent archive →
+`the-incredible-salt-mine` (a Jeralyzer member channel). Two outcomes:
+
+1. **Agents can clip TISM** — every held TISM video gets its media on disk, through the editor, so
+ `fetch_clip` / `fetch-windows` / `reports prepare` / report-to-video cut it locally.
+2. **Readers can play TISM** — the site plays the video in the transcript page (click-to-seek works) from a
+ torrent. Nothing is hosted on Cloudflare: the bytes come from peers, and a home seeder behind a VPN is the
+ **peer of last resort** — it seeds a torrent only while no other seeder has it.
+
+## Rulings (operator, 2026-10-09)
+
+- **Pilot on `jeralyzer-private` first**; public Jeralyzer is a later, separate ruling.
+- **No web seed, no R2.** A **home seeder only**, behind a VPN, with an app layer that seeds a torrent only when no
+ other seeder exists (peer of last resort). Accepted consequence: a torrent with no other seeder plays only while
+ the home seeder is up.
+- **ASR for the 3 new videos only** (`pnpm ops transcribe-one`); the other 161 keep their YouTube captions.
+- *Open:* the attribution line for the archived copy on the video page (the archive's README names its maker and
+ sources). Records state facts; the wording is the operator's.
+
+## What is there (measured 2026-10-09)
+
+| | |
+|---|---|
+| Archive | `<archive drive>/v1-torrent/`: `Uploads/YouTube.zip` (36.5 GB, **stored**, 183 folders), `SoundCloud.7z` (2 mp3: TISM001/002), `3rd-party-Odysee.7z` (2 mp4, solid LZMA2), `Images.7z`, `Official-page-archives.7z`, `README-v1.html` |
+| YouTube.zip folders | 183: **169 with media** (36.4 GB, **222.7 h**), 10 `[LOST]` (source.txt only), 4 others without media; 33 carry an archive.org `youtube-<id>_archive.torrent`; each folder has yt-dlp `.info.json`/`.description`/thumbnail, or `description.txt` + `source.txt` |
+| Codecs (ffprobe, in place) | 124 mp4 h264/aac with the **moov at the back**, 21 mp4 h264/aac faststart, 9 webm vp9/opus, 11 mkv vp9/aac, 2 mkv h264/opus, 1 mp4 av1/aac, 1 mkv av1/opus. Heights: 109×360p, 35×720p, 21×1080p, 4×144p |
+| Corpus | `the-incredible-salt-mine` (handling youtube, the channel is deleted): 171 videos, each `metadata.info.json` + cues (161 YouTube VTT, 10 `transcript.json`), **no media**. All 171 ids are in the archive. 3 archive videos with media are **not held**: `6ijPdSuZdws`, `VcFA_LOnQoc`, `xLgf_MGQzXk` |
+| Disk | the archive drive has 849 GB free; the saved-video store already lives on it (`archilyzer-media/saved-videos`) |
+
+## Gaps in the code (verified)
+
+- **No local-file ingest.** Every import takes a URL or an archive.org identifier (`import-one`,
+ `import-archive-org`); `sourceKind` is `video | social`. Only the downloader attaches a container to a held
+ record (`persistSourceVideo`, `common/lib/savedVideo-server.ts:102`).
+- **A clip window never reads a saved container.** `fetchWindowAction`
+ (`editor/app/channels/[slug]/videos/[id]/videoActions.ts`, ~1080) asks `findContainingClipWindow`, then the
+ network; only `full: true` returns the saved container (~1259), and only `evidenceClip-server.ts` (reports
+ prepare, report-to-video) cuts from it. For a deleted channel, a window request fails every time today.
+- **The sites already play files and seek**: every player behind `PlayerProvider.tsx` exposes `seekTo`, and cue
+ rows call it (`TranscriptModal.tsx` `CueRow`), so click-to-seek needs nothing new.
+- **No torrent code beyond archive.org ingest** (aria2c, `archiveOrgTorrent*.ts`). No WebTorrent anywhere.
+- **The export site ships a root-scope service worker** (`export/service-worker/site-sw.js`). WebTorrent 2's
+ browser streaming needs a service worker (`client.createServer({ controller })`), so the two must be one worker.
+ The site sends no CSP, so wss trackers and blob media are not blocked.
+
+## Slices
+
+### D1 — attach local media to held videos `[unit]` (clippability; nothing public)
+
+- `common/controller/attachMedia.ts` + job kind `attach-media` (drainable, `needsMedia`), ops action
+ `attach-media {slug, source, items?:[{id, path}], match?, createRecords?, dryRun?}`, CLI `archilyzer media attach`.
+- **Sources:** a directory, or a **zip read in place** (a stored entry is copied straight out, no temp dir; a
+ deflated or 7z entry is extracted to a scratch dir on the target disk first). The id comes from the folder's
+ trailing `(<11-char id>)`, else the media file's `-<id>.` suffix, else an explicit item. `dryRun` lists matched,
+ unmatched, already attached, not held and `[LOST]`.
+- **Writes** through `persistSourceVideo` (the one writer): the container into the saved-video store as
+ `source-media.<ext>`, `keepReason: "pin"`, a new `origin: {kind: "local-archive", archive, entry, sha256,
+ attachedAt}` (which archive, which entry, its hash). Refuses over an existing pointer unless `replace`; never
+ modifies the archive. Guards: `assertSavedVideosStoreWritable`, the media-reachable guard.
+- **Not held** (the 3): `createRecords: true` writes `metadata.info.json` from the folder's yt-dlp `.info.json`
+ (else from `description.txt`/`source.txt`, the title from the folder name), then `pnpm ops transcribe-one` for each.
+- `[LOST]` folders are listed, never attached.
+
+### D2 — clip windows cut from a saved container `[unit]` + `[spec ops-api]`
+
+- In `fetchWindowAction`, after the clip-window cache and BEFORE the network: when the video has a saved container
+ covering `[from − pad, to + pad]`, cut the window locally (evidenceClip's ffmpeg path), write it into
+ `data/<id>/clips/` like a fetched window with provenance `{source: "saved-video"}`, and answer `cached: true`.
+ `fetch-windows` batches then use no network for such videos. Helps every persisted video, not only TISM.
+- `mcp/src/instructions.ts`: a video held locally is cut without a fetch.
+- Starts after release 19's A5 merges (A5 edits the same fetch-window code: dedupe + queue key).
+
+### D3 — streamable copies + torrents `[unit]`
+
+- `common/lib/playableMedia.ts` picks a **lossless** remux (`-c copy`) per container: h264/vp9/av1 + aac/opus → mp4
+ `+faststart`; vp9/av1 + opus in webm stays webm. 169 copies, ~36 GB, on the archive drive under
+ `archilyzer-media/playable/<slug>/<id>/<id>.<ext>`, keyed by the source's sha256 (a re-run is a no-op).
+- One **single-file torrent per video** (`create-torrent`, from the WebTorrent ecosystem): piece length scaled to the
+ size (256 KiB–1 MiB), no `urlList`, `name` = `<id>.<ext>`, no `comment`/`createdBy` naming a person or a private
+ URL. The announce list is not in the info dict, so the infohash depends only on the bytes. The pilot announces
+ only to a **self-hosted tracker**, so nothing public learns the infohashes. Going public later means adding public
+ WSS trackers (browsers) and UDP trackers (desktop clients) to the magnet: same infohash, no re-hash.
+- Job kind `prepare-playable` (heavy: runs in release 19 B1's `pnpm heavy` slot once that lands).
+
+### D4 — publish the torrents `[unit]`
+
+- `site.json` gains `playable: { channels: string[], trackers: string[] }` (schema + SITE.md regenerated): the
+ channels whose records carry a torrent, and the announce URLs that site's magnets name (the pilot: the
+ self-hosted tracker only).
+- The index stage writes `.export-index/sites/<id>/playable.json` (id → infohash, magnet, mime, bytes). The site's
+ `inputSig` includes it, so adding a torrent makes the build stale. No media bytes leave the machine.
+- Compose: records of a playable channel gain `playable: {magnet, infoHash, mime, bytes}` in the published JSON;
+ each `.torrent` ships in the bundle (`/t/<id>.torrent`, ~170 small files, within the 15,000 cap).
+ `corpus.json` / `llms.txt` name the field.
+
+### D4b — the seeder: peer of last resort, behind a VPN `[unit]` + a manual network test
+
+- `archilyzer seed`, a long-running node service: WebTorrent 2 in node, with WebRTC via `node-datachannel` so it
+ serves browsers, and TCP/uTP for desktop clients. It seeds the playable manifests of the sites named in settings
+ (`seeder: { sites, maxUploadKiBps, maxConnections, pollSeconds, standbyAfterSeconds }`, SETTINGS.md regenerated).
+- **Last resort**, per torrent: the seeder polls the trackers' scrape (`complete`, minus itself) and its own peer
+ view.
+ - Other seeders present for `standbyAfterSeconds` → **standby**: stop announcing, close peers, keep the data.
+ - No other seeder (`complete` = 0) while a leecher announces, or a connected leecher with no other source → resume.
+ - Both edges have hysteresis, so it does not flap, and the log names each transition and why.
+ - Known cost: if the last other seeder leaves mid-stream, the viewer stalls for up to `pollSeconds` until the
+ seeder resumes.
+- **Behind a VPN, with no way around it:** the seeder runs in its own network namespace or container whose only
+ route is the VPN tunnel (a docker compose profile `seeder` with a WireGuard sidecar). Kill switch: tunnel down
+ means no network, never the home connection. ICE gathers candidates only from the tunnel interface, so WebRTC
+ never offers the home address (the classic WebRTC leak). `archilyzer doctor` reports the seeder's egress IP
+ against the host's and refuses to call the seeder healthy if they match.
+- **The risk to measure first:** whether the VPN's NAT lets browser peers connect directly (UDP hole punching).
+ If it does not, the options are a VPN with port forwarding or a TURN relay. That is a ruling, not a default.
+- Manual network test (recorded): a browser on another network plays a torrent through the seeder, and the
+ seeder's candidates and the tracker's view of its address show only the VPN exit.
+
+### D5 — the player `[unit]` + `[spec]` (export e2e)
+
+- `common/components/TorrentPlayer.tsx`: WebTorrent 2 (npm, bundled, dynamically imported only on a playable record),
+ `client.add(magnet)` → streamed into a native `<video>` through the service worker; the same
+ `seekTo`/`onReady`/`onProgress` shape as `FilePlayer`.
+- **One service worker:** `site-sw.js` gains WebTorrent's stream handler on its own path prefix (no second
+ registration); sites with `pwa: false` register a minimal worker for it alone.
+- **Choice rule in `PlayerProvider`:**
+ - A playable record whose platform copy is gone (`isDeleted`/unavailable, as for all of TISM) plays the archived
+ copy by default.
+ - A record whose embed still works shows "Play archived copy".
+ - There is no file fallback: with no peer after a timeout, the player says no one is seeding it right now and
+ offers the magnet and `.torrent` ("seed this").
+ - A status line shows peers and download rate.
+- e2e: a fixture torrent (a few seconds of video) seeded by a node WebTorrent peer the export e2e starts, announced
+ to a local tracker the e2e starts. Checks: it plays, a cue click seeks, and the no-peer state shows once the seed
+ is stopped.
+
+### D6 — the rest of the archive `[none]` → `[unit]`
+
+- SoundCloud TISM001/002 (audio) and the two Odysee mirrors: a dryRun maps them to held episodes by title and
+ duration; each is attached as alternate media or skipped, by operator ruling. `Official-page-archives.7z` and
+ `Images.7z` are out of scope.
+
+## Graph and order
+
+```
+D1 (attach) ──► D2 (window cut; after release 19 A5) ← agents can clip TISM from here
+D1 ──► D3 (copies + torrents) ──► D4 (publish torrents) ──► D5 (player) ──┐
+ └──► D4b (seeder + VPN) ─────────────────────┴► pilot on jeralyzer-private ──► public ruling
+```
+
+D1, D3 and D4b touch no file owned by tracks A/B/C and may start now. D2 waits for A5. D4 and D5 wait until release
+19 merges (the ops routes, `_cli.ts`, the `site.json` schema docs).
+
+## Rollout (pilot)
+
+1. `pnpm ops attach-media --json '{"slug":"the-incredible-salt-mine","source":"<archive>/Uploads/YouTube.zip","dryRun":true}'`.
+ Expect about 166 held and matched, 3 with media but not held (`6ijPdSuZdws`, `VcFA_LOnQoc`, `xLgf_MGQzXk`) and
+ 10 `[LOST]`; the dryRun gives the exact counts, including held videos with no media in the archive.
+2. The same without `dryRun`, plus `createRecords`, then `transcribe-one` for the 3.
+3. MCP `fetch_clip` on a TISM cue → `cached: true`, provenance `saved-video`, no network.
+4. `prepare-playable`.
+5. Start the self-hosted tracker and `archilyzer seed` in the VPN namespace, and check its egress IP.
+6. Build `jeralyzer-private` and deploy it locally.
+7. From a browser on another network: play and seek. A second browser that finishes and seeds sends the home
+ seeder to standby; closing it makes the seeder resume.
+8. Public ruling → Jeralyzer (public trackers added to the magnets, same infohashes).
+
+## Verification
+
+- D1: unit tests over a fixture zip (stored and deflated entries, id matching, dryRun, refusing an overwrite,
+ provenance).
+- D2: route/action unit tests plus one `ops-api.spec` case.
+- D3: the remux decision table, and torrent determinism (same input → same infohash).
+- D4: a compose test (the field, the `.torrent` files, `inputSig` moves).
+- D4b: the standby/resume state machine against a fake scrape source (both edges, hysteresis), a kill-switch config
+ test, and the manual network test.
+- D5: the export e2e above.
+- Privacy: no personal identifier of the archive's maker (donation addresses and the like) in any published file;
+ no private site URL in any torrent.
diff --git a/plans/stats-cache-key.md b/plans/stats-cache-key.md
@@ -1,6 +1,6 @@
# The stats cache key (fix, 2026-09-28)
-Branch `fix/stats-cache-key` from `main` `ac438bbc`. Merged: not yet. Rolled out: not yet.
+Branch `fix/stats-cache-key` from `main` `ac438bbc`. **Merged `10cefd15` (2026-09-28). Rolled out with release 15** (the recount; the homepage at 77,547 transcripts on 2026-09-30, `release-15.md` "Rollout"). Verified in the tree 2026-10-09: the cache keys on `indexSignature` (`common/controller/buildStats.ts:119`), and `STATS_DOWNGRADE_ENV` (`:134`) guards a downgrade.
## What was wrong