commit 5713fa4d8bc450ac33cfbaa7a964ccf2367e70f8
parent 3a3c37a48707a734aea64b53c753d2b258fe645c
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Fri, 11 Sep 2026 12:40:43 -0400
plans: channel priority, as shipped
STATE.md gains the entry (the shas, the two near-misses — the keep decision
and the hand-made order — and the rollout order, which is: stop the editor,
back up settings.json, run the migration with no flag, restart).
FACTS.md gains the anchors: the model compiles rather than being consulted,
`isDefaultChannelPriority` is the whole bypass and lives in the model because a
controller cannot import the runner, `laneDispatchRoot` is the one answer to
which tree a lane dispatches from, paused is a filter on the channel LIST, one
writer now covers `root`, the seeding rule, the dense/idempotent legacy read,
and the live dry-run numbers. Pinned to `3a3c37a`.
channel-priority.md gains "As shipped": the five slice tips, the eight
divergences from the plan (including the three the plan could not have known —
the keep decision, the seed, and the End-focus recompile), the banner's
End-focus decision, and the dry-run table's numbers.
The changelog entry is the operator's version, and says plainly that nothing
migrates until they run the migration.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
4 files changed, 220 insertions(+), 3 deletions(-)
diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md
@@ -1,6 +1,7 @@
# Changelog
## [Unreleased]
+- **Channels have priorities now, and the auto-queue's rules are generated from them.** Focusing on one group of channels — "finish Jeralyzer, hold the rest" — used to mean hand-editing four rule trees, and the only per-channel switch on `/channels` was **Sync included / excluded**, which gated sync and nothing else. Every channel row now carries a **tier** — *Normal*, *Low* or *Paused* — plus a corpus-wide **focus**: pick channels and press *Focus these*, or focus a whole site, and every lane runs the focused channels until they have nothing left, then falls through to the rest and retakes the lane the moment new focused work arrives. A focus is one fact, not four: the download, transcription, digest and speaker lanes are all held by it, and each lane's console carries a banner saying what is focused, how much of it is pending there, how many channels are held behind it, and **End focus**. Behind the disclosure on each row, any single operation can be pinned to its own tier — "keep this channel's playlist current but stop downloading it" is a *download* pin, and *Sync only* is a preset for it. A paused channel is dropped from the automatic lanes and from the sync scheduler, and **still runs from every Run button**: a hold is not a stop. Its row dims and its Build toggle is untouched, because publishing is a different question from scheduling. The four rule trees are **generated** from all of this: the policy editor on an operation's page shows them read-only with a link back to `/channels`, keeps editing everything that is not generated (enable, workers, order, the replace-auto-captions lane), and the channel leaves you had are replaced by the compiled ones. **Sync included / excluded is gone**, and it is the same statement said better: the 15 channels that carried it become *paused for sync alone* and keep every lane they were on. **Nothing is migrated until you run the migration** — `pnpm -C common exec tsx bin/migrate-channel-priority.ts`, with `--dry-run` first to see the table; back up `settings.json`, stop the editor, run it, restart. Until then the editor behaves exactly as it does today, and the first tier you set seeds itself from the rule trees you already had, so the order you hand-built is kept either way.
- **Every pipeline is dispatched by one thing now: its lane’s runner. The two corpus sweeps and the arbiter are gone.** Digest and Speaker work were driven by a *sweep* — a corpus walk armed by its own switch, with its own scope, its own order and its own console — while Download and Transcription were driven by the auto-queue runner, with rules, a claim ladder, a next-up and a pick log. Two mechanisms, two vocabularies, two sets of bugs. There is one: **each of the four lanes has a runner, a rule list, and Start / Drain / Stop beside its pause**, on the operation’s own page. Arming a corpus pass is switching the lane on; scoping it to particular channels or operations is a *rule*, written the same way auto-transcribe’s have been written since it shipped. The dashboard and the widget keep a one-click switch per lane — **Run every channel** / **Stop the lane** where they said *Sweep every channel* / *Stop sweeping* — and the scope lives on the lane’s page, where you can see what it would do next. **Your armed scope is carried over, and no lane is switched on that was not.** The ten settings fields the sweeps used (`digest.sweepEnabled`, `sweepChannels`, `recencyOrder`, `recencyReach`; `backfill.sweepEnabled`, `sweepKinds`, `sweepChannels`, `order`, `reach`, `weight`) are read once and written into the lane’s rules the first time the editor starts: a sweep armed on three channels becomes three rules, an unscoped one becomes a single *every channel* rule, and a disarmed sweep becomes a switched-off lane. What is retired rather than migrated: **Reach**, because a rule already orders every video it claims across every channel — which rule goes first is the rule list’s job; the digest **order**, whose real meaning was always *newest day first, shortest video within a day* and which the lane spells as **Shortest first** (pick *Newest first* there if you want the date order alone); and the backfill lane’s **Resource share**, which was one number answering two different questions. A lane now stands aside for transcription when it would actually compete for the graphics card, and keeps its slots when it would not — so speaker-naming over an LLM endpoint no longer parks itself behind a transcription it was not competing with. **The arbiter, which never ran a single unit in production, is deleted**; the runner is what dispatches an operation-named rule. **Nothing on disk changes**, and the retired keys are left in `settings.json` — harmless, ignored, and yours to delete.
- **The transcode operation is gone — it never fired.** A channel page had a *Transcode* stage, `/operations/transcode` had a "no console here" panel, `/cleanup` offered "Clear failed transcodings", and the video list drew a third status dot — all for a re-encode step built against two failures that never happened in production: in 68 channels, no snapshot has ever listed a video as missing its target format, no `failed-transcodings` file has ever held an id, and only four channels even met the stage's gate. Transcription never needed it — a video whose audio is in another format transcribes from that file. What stayed is everything that was never the operation's: the download path still re-encodes what it extracts itself, the video page still offers **Transcode audio.\<ext\> → \<fmt\>** per file, and both audio-format sweeps on the Cleanup stage and `/cleanup` are unchanged (gated on the channel having an `audioFormat`, which is what they compare against). The snapshot bucket behind the sweep is `wrongFormatAudio` now — its operator-facing name — and old reports keep their stray key until their next refresh. A `?stage=transcode` bookmark opens the channel overview. **Nothing on disk changes.** Also: the Pool's running-jobs list names the eight kinds its buttons enqueue, and the site's Search aliases tab no longer carries a "no site selected" branch that could not run.
- **A site has tabs, and the family has one page.** Charts, Search aliases, Deploy, Build and Homepage were five sidebar entries beside *Sites*, three of them reading the site from a `?site=` parameter the sidebar picker had to seed, one of them (Build) about no site at all, and one (Homepage) about the family's own hub. A site is one thing now: **`/sites/<id>` is Settings · Charts · Search aliases · Publish**, the site named in the path, the picker following it (and Dashboard and Channels following the picker). **`/sites` is the family page**: the list, then *Release notes*, *Build all sites* with the Basic/Docker mode, the *Hub*, and the *Pool* — the corpus-wide index, stats, sidecar and archive jobs — folded under a disclosure. Search aliases keep both sections on the site's tab: the global dictionary and the site's overrides. Every button, label and log is unchanged; "Select a specific site from the sidebar" is gone because a site's page always has one. The five routes redirect — a `?site=<id>` bookmark lands on that site's tab (the query rides along), `?site=__all__` and the bare routes on `/sites`; a bookmark to a deleted site 404s there exactly as `/sites/<id>` does. The Sites group is one entry; the nav is **eleven**, the IA doc's end state. **Nothing on disk changes.**
diff --git a/plans/FACTS.md b/plans/FACTS.md
@@ -3587,3 +3587,93 @@ Unchanged from the Phase 0 entry, plus one thing Phase 1 learned twice: **`git a
it** (it is untracked, not ignored), which carries the Turbopack panic into every worktree of
that commit. Add by path. A worktree also needs a composed fixture site copied into its
`export/public` or the export webServer 500s and Playwright times out at 120 s.
+
+## Channel priority (verified 2026-09-11) — one tier per channel, four compiled trees
+
+Branch `channel-priority/s5`, off S0's `28bfee3`, merging `s1`–`s4` and closing the twelve
+review findings. Unmerged; every line:number below is at `d487519`. The plan and its "as
+shipped" record are [`channel-priority.md`](channel-priority.md).
+
+**The model is `settings.channelPriority` (`common/lib/channelPriority.ts`): one focus
+selector plus a sparse map of channels that differ from the default — a `tier`
+(`normal|low|paused`), an optional `rank`, and a per-operation `overrides` map over the five
+`PRIORITY_OPERATIONS` (`sync` plus the four lanes). It COMPILES, it is not consulted.**
+`compileLaneRoot` (`:486`) turns it into a lane's `AutoQueueGroup` — `prio-focus` >
+`prio-normal` > `prio-low` > `prio-all`, all strict — and the ordinary engine dispatches over
+it, so `buildPendingByLeaf`, `selectNextWork`, `laneLimit`, `pauseGates` and every `held` key
+are untouched. A focus holds the rest because strict descent already does that, re-asked on
+every grant.
+
+- **`isDefaultChannelPriority` (`channelPriority.ts:350`) is the whole bypass.** No focus and
+ no channel entry means the compiler never runs and the stored trees stand byte for byte. It
+ lives in the MODEL, not in the runner, because five callers in two packages ask it and a
+ controller cannot import the runner without an import cycle (`backfillReacquire` does).
+- **`laneDispatchRoot` (`controller/autoRunner.ts:441`) is the one answer to "which tree does
+ this lane dispatch from".** The runner loop and `computeLeafPending` call it, and so does the
+ editor's status payload (`editor/app/operations/channelPriorityView.ts`) — a console that
+ compiled its own would be a second compiler that merely agrees. It takes a
+ `PriorityDispatchContext` (`{model, focusSlugs}`), which is the widened shape that let the
+ editor pass its own resolution in.
+- **`priorityContextFor(paths, settings)` (`:400`) caches the resolved focus on a 60 s TTL
+ keyed by `(document, sitesDir)`.** The TTL is not a memo: a `{kind:"site"}` focus tracks the
+ site's `channels[]`, so a channel added to the focused site joins the focus with no settings
+ write. Only a site focus reads the sites directory. The settings object is a PARAMETER —
+ both call sites already hold it.
+- **Paused is a filter on the channel LIST, not a tree shape.** `listChannelMeta(paths, kind,
+ model)` (`:315`) drops `isChannelPaused(model, slug, kind)` — the EFFECTIVE tier for the lane
+ being listed — and it is the single source of that list for both the runner loop and the
+ status panel. A tree cannot express exclusion: a catch-all matches everything.
+- **`siteChannelIndex(paths)` (`lib/site.ts:324`) is the one spelling of "siteId -> slugs".**
+ It was inlined four times (runner, sync tick, writer, status payload).
+
+**ONE WRITER, and since this slice that means `root` too.** `saveChannelPriorityAction`
+(`editor/app/channels/actions.ts:761`) is the only function that writes
+`settings.channelPriority`, and it recompiles the four roots in the same `writeSettings` call,
+spreading each policy so `held`, `snoozeUntil`, `enabled`, `order` and `maxWorkers` survive.
+Three actions that used to write a root no longer may while a model exists:
+`saveAutoQueueAction` and `armLaneAction`-with-a-scope refuse server-side with a message
+naming `/channels` (S4's read-only `PolicyTreeEditor` is the courtesy; this is the rule), and
+`prioritizeChannelDownloadAction` is a priority EDIT (`{kind:"promote"}`) that takes the first
+rank and shifts the rest. `createChannel`/`deleteChannel` recompile through the same writer.
+The writer takes one option, `enableLanes`, because "Add to the top of the download queue"
+has always meant prioritise AND switch on, and two writes would race on one settings file.
+
+**THE SEEDING RULE, and it is what keeps a hand-made order alive.** The bypass is
+all-or-nothing, so the first click that makes a document non-default also stops the stored
+trees being dispatched from — and a hand-made lane order lives ONLY in those trees. So when
+the stored document is default AND `hasCompiledLaneRoots(settings.autoQueue)` is false
+(`channelPriority.ts:682` — `prio-*` ids are produced by the compiler and nothing else), the
+writer seeds from `channelPriorityFromLegacy` (`:748`) and applies the edit on top. Never on a
+`recompile`: creating a channel is not a statement about priority. A DEFAULT document compiles
+nothing, matching the runner's own bypass — clearing the last priority must not overwrite the
+stored trees with an all-normal compile.
+
+**`channelPriorityFromLegacy` renumbers DENSELY and is idempotent.** Merging the two lane
+orders by "the lower index wins" produces collisions (four pairs live), and a collision falls
+through to `orderWithin`'s slug fallback — neither lane's order. Tie rule: merged index, then
+the TRANSCRIPTION lane's own order, then slug. And it returns the stored document untouched
+once any root carries a `prio-*` id, because compiled channel leaves ARE bare channel leaves
+and a second run would otherwise re-derive the order from the tree that order produced.
+
+**A HOLD IS NEVER A STOP, and `backfillReacquire.decideKeep` (`:392`) is where that nearly
+became data loss.** It asked `policyDrawsBucket` on the stored transcription root; on the
+compiled root a channel paused for transcription has NO LEAF, so the honest reading answers
+"no-leaf" for exactly the channels just put on hold and unlinks their re-acquired audio. The
+pause is now asked FIRST and KEEPS (reason `"paused"`), below only "nothing landed" and "not
+this bucket's shape"; the leaf question is then asked of the compiled root, built for that one
+slug — which tier group holds a channel changes the order, never whether a leaf draws a bucket.
+
+**`excludeFromSync` is DELETED** — field, sanitizer clause, action, toggle, and both legacy
+skips in `syncScheduler.ts` (`:132`, `:259`), `syncAllChannelsAction` and `stationWorkFor`.
+`isChannelPaused(model, slug, "sync")` is the one question all five ask. `parseChannelConfig`
+DROPS the key rather than carrying it, so an un-migrated config parses to a channel that says
+nothing about sync and therefore syncs — which is what it did before the flag existed. That is
+why `common/bin/migrate-channel-priority.ts` reads the raw `config.json` for the flag, and why
+the editor's legacy seed passes `{}`: the seed is after the lane ORDER, not the flag.
+`excludeFromBuild` and `excludeFromCleanup` are untouched — different axes.
+
+**The live corpus, measured through the migration's `--dry-run` against a read-only copy of
+`settings.json`:** 68 channels, 28 entries, 14 ranked (dense 0–13, `quartering-live` first),
+15 carrying `overrides: {sync:"paused"}`, no focus, every base tier `normal`. All four
+compiled roots are `prio-normal(68) > prio-all`. No lane's membership moves; only the `sync`
+operation loses anyone.
diff --git a/plans/STATE.md b/plans/STATE.md
@@ -3,7 +3,8 @@
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.
-**Last updated:** 2026-09-08 — **one-core Phase 1 shipped** on branch `one-core/phase-1`
+**Last updated:** 2026-09-11 — channel priority shipped on `channel-priority/s5` (see the
+entry below the Phase 1 record). **Previously:** 2026-09-08 — **one-core Phase 1 shipped** on branch `one-core/phase-1`
(`7f294df` → `81a663f` plus a docs commit, 36 commits, not merged): **dispatch is one scheduler, and the lane is the
noun.** The slice-level record — every sha range, every divergence, both operator gates — is
[`one-core-phase-1.md`](one-core-phase-1.md); the umbrella is
@@ -155,6 +156,49 @@ assertion, and it was right: writing the two new snapshot entries doubled `/chan
and Transcribe coverage, because that page passes the external ids into `buildOperationBands`
and `addRegistryEntry` folded them on top of `addExternalBands`. Fixed in `d8754d2`.
+**2026-09-11 — channel priority shipped** on branch `channel-priority/s5`
+(`28bfee3` → the tip below, four merge commits plus eight of its own, **not merged**):
+**one tier per channel, one focus, four COMPILED lane trees.** S0 (`28bfee3`) is the model;
+S1 (`2710195`) dispatch; S2 (`76c5784`) sync; S3 (`88da736`) the `/channels` UI and the one
+writer; S4 (`9fcc8b4`) the banner and the read-only tree; S5 merged all four in order (no
+conflicts — the slices touched disjoint files), closed the twelve parked review findings, ran
+the migration's dry run against the live corpus and deleted `excludeFromSync`. Anchors:
+[`FACTS.md`](FACTS.md#channel-priority-verified-2026-09-11); the record with every divergence
+is [`channel-priority.md`](channel-priority.md)'s "as shipped".
+
+- **The model compiles, it is not consulted.** `settings.channelPriority` → `prio-focus` >
+ `prio-normal` > `prio-low` > `prio-all`, all strict, per lane. No dispatch code changed: a
+ focus holds the rest because strict descent already does, re-asked on every grant. An absent
+ document is today's behaviour byte for byte.
+- **The data-loss find.** `backfillReacquire.decideKeep` asked `policyDrawsBucket` on the
+ transcription root — on a COMPILED root a channel paused for transcription has no leaf, so
+ it would have answered "no-leaf" and unlinked the re-acquired audio of exactly the channels
+ an operator had just put on hold. The pause is asked first and KEEPS. A hold is never a stop.
+- **The order that nearly went.** The bypass is all-or-nothing, so the first tier click on
+ `/channels` stops the stored trees being dispatched from — and the two hand-made 9+9 lane
+ orders live only there. The one writer now seeds from `channelPriorityFromLegacy` when the
+ stored document says nothing and the stored roots are not already compiled. And a corpus
+ whose roots ARE compiled recompiles even for a default document, or ending a focus would
+ leave `prio-focus` at the head of a tree the runner is bypassing to.
+- **One writer of `root` too.** `saveAutoQueueAction` and `armLaneAction`-with-a-scope refuse
+ server-side while a model exists; `prioritizeChannelDownloadAction` is a priority edit;
+ create/delete recompile.
+- **`excludeFromSync` is deleted** — field, sanitizer clause, action, toggle, and all five
+ legacy reads. `excludeFromBuild`/`excludeFromCleanup` untouched.
+
+**THE MIGRATION HAS NOT BEEN RUN — that is the operator's step at rollout.**
+`common/bin/migrate-channel-priority.ts --dry-run` against a READ-ONLY copy of the live
+`settings.json` (no config.json or settings.json mtime moved) says: **68 channels, 28
+entries, 14 ranked, 15 pinned `sync=paused`, no focus, every base tier `normal`**, and all
+four compiled roots `prio-normal(68) > prio-all`. The ranked order is
+`quartering-live, the-quartering-rumble, the-quartering, HasanAbiVODs3, nuxanor, hasanabi,
+darlingstrawb, rekietalaw-rumble, chibi-reviews, nux-taku, destiny, omnivods-odysee,
+leaflit-rumble, piratesoftware` — dense 0–13, merged index then the transcription lane's
+order then slug. No lane's membership moves; only `sync` loses anyone. **Rollout order:**
+(1) stop the editor, (2) `cp settings.json ~/settings.json.pre-priority`, (3)
+`pnpm -C common exec tsx bin/migrate-channel-priority.ts` (no flag), (4) restart the editor —
+the runner re-reads the document per tick, but the worker pool and the heartbeat arm at boot.
+
**Next:** [`relocate-channel-media.md`](relocate-channel-media.md) FIRST, 3 slices —
`/home` is at **100 %, 6.9 G free**, and the mechanism (a symlinked `data/` plus a
`config.dataDir` record and four guards) is orthogonal to phase 2, so it neither waits on
diff --git a/plans/channel-priority.md b/plans/channel-priority.md
@@ -1,7 +1,10 @@
# Channel priority — one tier per channel, one focus, four compiled trees
-**Status:** **S0 (the contract) is implemented** on branch `channel-priority/s0`, off `e74f005`.
-S1-S5 are design only. Anchors are at `61eae05` on `one-core/phase-1`.
+**Status: SHIPPED** on branch `channel-priority/s5` (unmerged), 2026-09-11. All six slices
+are implemented; see **"As shipped"** at the foot of this file for the shas, the divergences
+and the live migration's dry-run numbers. Anchors are in
+[`FACTS.md`](FACTS.md#channel-priority-verified-2026-09-11). Everything above that section is
+the plan as written, kept as-is.
Operator decisions taken, numbered as the Open questions section below: **Q1 — collapse** the
two live per-lane channel orders to ONE (the model has one rank per channel). **Q2 — no**, a
@@ -514,3 +517,82 @@ auto-ending a focus when its work hits zero (the banner reports it, the operator
(one scheduler, one gate definition, one writer per settings block, a hold is never a stop),
so it belongs in the same interlude slot as `relocate-channel-media.md` and should cite them
rather than restate them.
+
+
+## As shipped (2026-09-11)
+
+Branch `channel-priority/s5`, off S0's `28bfee3` on top of `e74f005` (`one-core/phase-1`).
+**Unmerged**, and the migration has **not** been run — that is the operator's step at rollout.
+
+### The slices, in merge order
+
+| slice | branch | tip | what it is |
+|---|---|---|---|
+| S0 | `channel-priority/s0` | `28bfee3` | the model, the sanitizer, the compiler, the legacy read |
+| S1 | `channel-priority/s1` | `2710195` | `listChannelMeta`'s paused filter, `laneDispatchRoot`, the focus-hold log line |
+| S2 | `channel-priority/s2` | `76c5784` | `selectDueChannels`' predicate and its tier/rank/overdue order |
+| S3 | `channel-priority/s3` | `88da736` | `/channels` tier + bulk controls, `saveChannelPriorityAction` |
+| S4 | `channel-priority/s4` | `9fcc8b4` | the focus banner, the compiled ladder, the read-only tree |
+
+S1–S4 merged into S5 in order with **no conflicts** — the graph's disjoint-file-set claim held
+exactly. S5's own commits: the duplication fold (`7e3b282`), the keep decision (`13c5da4`),
+dense ranks and idempotence (`8c335d5`), one writer of `root` plus the legacy seed
+(`a5bb23f`), the seeding e2e (`a12aa14`), the migration script (`278e648`), the deletion of
+`excludeFromSync` (`dca3109`), the End-focus recompile (`d487519`) and the records.
+
+### Divergences from the plan
+
+1. **`isDefaultChannelPriority` lives in `lib/channelPriority.ts`, not in the runner.** S1 kept
+ it private ("a dispatch-side question"), S4 shipped a second copy in the editor. Five
+ callers in two packages ask it, and `controller/backfillReacquire.ts` is one of them — a
+ controller importing the runner opens an import cycle. The model owns it; `laneDispatchRoot`
+ is exported from `autoRunner.ts` and is what the status payload calls, so there is one
+ definition of the dispatched tree as well.
+2. **`siteChannelIndex(paths)` is new, in `lib/site.ts`.** The plan did not name it; four
+ inlined copies of "read every site.json, map siteId → slugs" did.
+3. **The keep decision is in scope after all.** The plan said dispatch was untouched except for
+ `listChannelMeta`. `backfillReacquire.decideKeep` reads the transcription ROOT, which the
+ compiler rewrites, and on a compiled tree a transcription-paused channel has no leaf — so
+ the unchanged code would have unlinked re-acquired audio for every paused channel. It now
+ asks the pause first and keeps (`"paused"`), then asks the leaf question of the compiled
+ root. This is the one dispatch-adjacent change the plan did not foresee.
+4. **The legacy ranks are renumbered densely**, with a stated tie rule (merged index, then the
+ transcription lane's order, then slug). The plan's "lower of its two indices" leaves four
+ collisions on the live trees, and a collision falls through to slug order — neither lane's.
+5. **The migration is idempotent by DETECTION, not by "the document already exists".** Compiled
+ channel leaves are bare channel leaves, so a second run would re-derive ranks from its own
+ output. `hasCompiledLaneRoots` (a `prio-*` id anywhere in a lane root) is the gate.
+6. **The one writer seeds from the legacy trees**, and a corpus whose roots are already
+ compiled recompiles even for a DEFAULT document. Neither is in the plan, and both are
+ correctness: without the first, the first tier click destroys the hand-made order; without
+ the second, End focus leaves `prio-focus` at the head of the tree the runner bypasses to.
+7. **One writer now means `root` as well as the document.** `saveAutoQueueAction` and
+ `armLaneAction`-with-a-scope refuse server-side while a model exists, and
+ `prioritizeChannelDownloadAction` is a `{kind:"promote"}` priority edit. The writer takes
+ one option, `enableLanes`, so "Add to the top of the download queue" stays one write.
+8. **`channel-sync-toggle.spec.ts` went in S3**, not S5 — S3 removed the control it drove.
+
+### The banner's End focus, and why the link stayed
+
+S4 built `FocusBanner` with an `endFocus?: ReactNode` slot and a "Channel priorities" link,
+and deliberately no writer of its own. S5 fills the slot on the lane consoles with
+`EndFocusButton`, a client component that posts `endFocusAction` — S3's named gesture, so it
+is the same writer, not a second one. The link stays beside it: the button ends a focus, the
+link is the way to everything else the document says.
+
+### The live migration, dry-run
+
+`SETTINGS_FILE` pointed at a read-only copy of the live `settings.json`, `TRANSCRIPTS_DIR` at
+the real corpus; no `config.json` or `settings.json` mtime moved. **68 channels, 28 entries,
+14 ranked, 15 pinned `sync=paused`, no focus, every base tier `normal`**, all four compiled
+roots `prio-normal(68) > prio-all`. Ranked order, dense 0–13:
+
+`quartering-live, the-quartering-rumble, the-quartering, HasanAbiVODs3, nuxanor, hasanabi,
+darlingstrawb, rekietalaw-rumble, chibi-reviews, nux-taku, destiny, omnivods-odysee,
+leaflit-rumble, piratesoftware`
+
+`omnivods-odysee` is the channel that is both ranked and excluded: it keeps its rank, keeps
+downloading, and loses only `sync`. No lane's membership moves.
+
+**Rollout:** stop the editor → `cp settings.json ~/settings.json.pre-priority` →
+`pnpm -C common exec tsx bin/migrate-channel-priority.ts` → restart.