commit f196bfe246958aa7125b27f3ea87af3f8d23e3d6
parent 1939ef917c4888eb13c68427ca6fcc4834819256
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Fri, 11 Sep 2026 11:06:52 -0400
plans: channel-priority design, sequenced as the second interlude item (Phase 1.5)
The /channels "enable/disable" never reached dispatch: it is two negative flags,
excludeFromSync (sync scheduler, Sync all, social due check) and excludeFromBuild
(buildIndex, buildStats), and autoRunner/operationBatch/autoQueuePolicy read neither, so
every lane draws from excluded channels today. Fifteen channels carry excludeFromSync.
Design: an ordered-tier document in settings.json ({focus, channels[slug] -> tier/rank}) in a
pure common/lib/channelPriority.ts that COMPILES to the four autoQueue[lane].root trees —
strict-mode pick() already gives focus-holds-the-rest, retake-on-new-work and
release-when-exhausted — with paused as one filter in listChannelMeta on the existing 30 s
TTL. Zero dispatch-code change. Live trees are all-strict, 22 bare leaves, nothing a compiler
would destroy. Slices S0 (contract) -> S1 dispatch, S2 sync, S3 /channels UI, S4 banners in
parallel -> S5 migration + deletion of excludeFromSync.
Roadmap: after relocate-channel-media, before one-core Phase 2, branched off this tip.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
3 files changed, 447 insertions(+), 2 deletions(-)
diff --git a/plans/STATE.md b/plans/STATE.md
@@ -158,8 +158,10 @@ and `addRegistryEntry` folded them on top of `addExternalBands`. Fixed in `d8754
**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
-nor complicates the contract work. Then phase 2 (the contract: one `ArchiveReader`), 3
-slices. Read
+nor complicates the contract work. Then [`channel-priority.md`](channel-priority.md) (one channel priority model that
+replaces the two `excludeFrom*` flags and compiles to the four lane trees; S0 contract, then
+S1–S4 in parallel, S5 migration last; built on a branch off this tip, merged after relocate).
+Then phase 2 (the contract: one `ArchiveReader`), 3 slices. Read
`common/architecture.test.ts`'s allow-list first — it is the shortest accurate statement of
what is still tangled, it shrank by one across phase 1, and no slice added an entry.
diff --git a/plans/channel-priority.md b/plans/channel-priority.md
@@ -0,0 +1,435 @@
+# Channel priority — one tier per channel, one focus, four compiled trees
+
+**Status:** design only, nothing implemented. Anchors are at `61eae05` on `one-core/phase-1`.
+
+The ask: *"focus on one group of channels (e.g. jeralyzer) and pause all others until the
+priority channels are done … rip out the current enable/disable feature on the channels page
+and replace it with a unified channel priority system … heavily influence the auto queue."*
+
+## What the tree says
+
+**The premise that `ChannelConfig.enabled` exists is FALSE.** `common/lib/channelConfig.ts:47`
+is `enabled: boolean` inside **`AudioCheckConfig`** (`:46-57`). `ChannelConfig` runs
+`:59-160` and has no `enabled`. What the `/channels` row actually carries is **two negative
+exclusion flags**, and they gate different things:
+
+| Fact | Where | What it gates |
+|---|---|---|
+| `excludeFromSync?: boolean` | `common/lib/channelConfig.ts:113`, sanitized `:278-280` | **Sync only.** |
+| — scheduler skip | `common/jobs/syncScheduler.ts:99` | `continue` in the due loop, no skip log |
+| — status projection | `common/jobs/syncScheduler.ts:193-197` | `autoSyncEligible` false |
+| — manual *Sync all* | `editor/app/channels/actions.ts:466-472` | skip reason `"excluded from sync all"` |
+| — social due check | `common/controller/fetchPosts.ts:180` | same predicate |
+| `excludeFromBuild?: boolean` | `common/lib/channelConfig.ts:112`, sanitized `:275-277` | **Publishing only.** |
+| — index build | `common/controller/buildIndex.ts:289` | channel never enters the built index |
+| — stats build | `common/controller/buildStats.ts:172` | contributes no stats |
+| `excludeFromCleanup?` | `common/lib/channelConfig.ts:117` | the `/cleanup` aggregate total only |
+| Writers | `editor/app/channels/actions.ts:394-409` (build), `:411-425` (sync) | both via `writeChannelConfig` (`common/controller/channels.ts:452-463`) |
+| UI | `editor/app/channels/components/ChannelsTable.tsx:423-428` / `:429-434` | `ChannelBuildToggle.tsx:18-42`, `ChannelSyncToggle.tsx:18-42`; row dimmed `:406-411`; sortable `:54-63,129-163` |
+
+**Neither flag reaches dispatch.** `common/controller/autoRunner.ts`,
+`common/controller/operationBatch.ts` and `common/jobs/autoQueuePolicy.ts` contain zero
+`excludeFrom*` references. `listChannelMeta` (`autoRunner.ts:288-296`) maps
+`listChannelConfigs` straight to `{slug, platform}` with no filter, so today an
+excluded-from-sync channel is still drawn by every lane. Snapshot generation, `recencyIndex`,
+the export app and `mcp/src` never read a channel config at all.
+
+**The lane trees are already a priority system, and the live ones are trivial.** From the
+repo-root `settings.json` (gitignored):
+
+| lane | enabled | order | held | root |
+|---|---|---|---|---|
+| transcription | true | listed | false | strict, 10 children: 9 bare `{type:"channel"}` leaves then `{type:"all"}` |
+| download | true | listed | false | strict, 10 children: 9 bare `{type:"channel"}` leaves then `{type:"all"}` |
+| digest | false | cheapest | false | strict, 1 child `digest-all` `{type:"all"}` |
+| backfill | false | newest | false | strict, 1 child `backfill-all` `{type:"all"}` |
+
+**Every one of the 22 live leaves is a bare channel or `all` leaf. Zero carry `match.bucket`;
+zero carry `match.operation`.** The two hand-made lists disagree: transcription names
+`quartering-live, the-quartering-rumble, the-quartering, HasanAbiVODs3, hasanabi,
+rekietalaw-rumble, nux-taku, nuxanor, leaflit-rumble`; download names `quartering-live,
+the-quartering, the-quartering-rumble, nuxanor, darlingstrawb, chibi-reviews, destiny,
+omnivods-odysee, piratesoftware`. Six channels are ranked in one lane and not the other, and
+the three shared Quartering channels are in two different orders.
+
+**A strict group is already "hold the rest until the higher one is empty".**
+`pick()` (`common/jobs/autoQueuePolicy.ts:487-513`) filters children to those with work
+(`:501`) and on `mode === "strict"` descends into the FIRST of them (`:503-505`). It is
+re-asked on every single grant, so: focus work present ⇒ nothing below it is picked; focus
+work exhausted ⇒ the next child runs; new focus work arrives ⇒ the very next pick retakes the
+lane. The runner rebuilds `pending` every tick from fresh snapshots (`autoRunner.ts:1087-1092`)
+and re-reads the policy each iteration (`:1093-1095`).
+
+**Sites are a clean partition.** 63 distinct slugs across six `transcripts/sites/*/site.json`,
+**zero in two sites**; 69 directories under `transcripts/channels/` (68 real + `.stfolder`),
+so 5 are in no site (`angryjoeshow`, `jfg-tonight`, `leaflit-rumble`, `omnimirror`,
+`piratsoftware-x`). `jeralyzer` = 30, `anilyzer` = 22, `hasanalyzer` = 5, `bonnellyzer` = 3,
+`rekietalyzer` = 2, `jasolyzer` = 1.
+
+**Sync ordering today.** `selectDueChannels` (`common/jobs/syncScheduler.ts:85-127`) gates on
+`scheduler.enabled` (`:87`), quiet hours (`:88-92`), `!config.url` (`:98`), `excludeFromSync`
+(`:99`), interval ≤ 0 (`:101-102`), already-running (`:104-107`), failure backoff (`:109-118`)
+and not-due (`:120-121`); it sorts **most-overdue-first** at `:125`, ties resolving
+alphabetically because `listChannelConfigs` returns slug order
+(`common/controller/channels.ts:401-403`). The tick caps the list at
+`maxConcurrentSyncs - running` (`editor/app/scheduler/runTick.ts:127-131`). No pause gate is
+consulted in selection (`pauseGates.ts:64-67`: sync's lane is `null`); dispatch refuses when
+the **download** lane is held (`editor/app/channels/[slug]/pipelineActions.ts:109-117`).
+
+## Decision
+
+**One document in `settings.json`, one tier per channel plus one focus selector, compiled into
+the four `AutoQueuePolicy.root` trees by the model's own writer. Paused is a filter on the
+runner's channel list, not a tree shape.**
+
+1. **`settings.json`, not 68 `config.json` files.** A focus is one corpus-wide fact with an
+ ordering *between* channels; 68 files cannot express a total order and cannot be written
+ atomically. `parseChannelConfig` is allow-list style (`channelConfig.ts:211-375`) and
+ silently drops unknown keys, so a per-channel rank would need a schema change in the file
+ that every downloader, sweep and build reads. `settings.json` already holds every other
+ dispatch decision (`autoQueue`, `syncScheduler`) behind one sanitizer and one writer.
+
+2. **COMPILE, not consult.** The deciding fact is the pair above: strict descent
+ (`autoQueuePolicy.ts:503-505`) is *exactly* the focus/hold semantics the operator described,
+ re-evaluated per pick at zero cost, and **the live trees contain nothing a compiler would
+ destroy** — 22 leaves, all bare. Consulting the model from the claim ladder would mean a
+ second priority mechanism beside the tree, two things to explain on
+ `HowPriorityWorks.tsx`, and a new re-evaluation clock. Compiling means **no dispatch code
+ changes at all**: `buildPendingByLeaf`, `selectNextWork`, `operationBatch`, `laneLimit`,
+ `pauseGates` and every `held` key are untouched, and "a zero limit is a hold, never a stop"
+ is preserved trivially because nothing new ever returns a limit.
+
+3. **Paused is removed from the channel LIST, not from the tree.** A tree cannot express
+ exclusion (an `{type:"all"}` catch-all matches everything, and first-match-wins would let a
+ catch-all placed above the Low group swallow Low's work). `listChannelMeta`
+ (`autoRunner.ts:288-296`) is the single source of the channel list for **both** the runner
+ loop (`:1080-1092`) and the status panel (`computeLeafPending`, `:574-582`), refreshed on a
+ 30 s TTL (`CHANNEL_LIST_TTL_MS`, `:141`). Filtering Paused there makes a paused channel
+ invisible to all four lanes, catch-all included, in one predicate.
+
+4. **`excludeFromSync` dissolves into the Paused tier; `excludeFromBuild` stays.** They are
+ different axes: one is scheduling, one is publishing, and the brief's own rule is that the
+ lowest tier must not gate export. The `/channels` row keeps its Build toggle and loses its
+ Sync toggle.
+
+5. **No drag-and-drop, no per-lane overrides.** Tier is a `<select>`; intra-tier order is an
+ optional integer `rank` (migration seeds it; ties fall back to slug order).
+
+## The model
+
+`common/lib/channelPriority.ts` — new, pure, imports only `./autoQueueTypes` (lib → lib, so
+**`common/architecture.test.ts`'s ALLOWED list gains no entry**; `FORBIDDEN` at `:31-35` only
+bars `lib → controller|jobs`).
+
+```ts
+export const CHANNEL_TIERS = ["focus", "normal", "low", "paused"] as const;
+export type ChannelTier = (typeof CHANNEL_TIERS)[number];
+
+export type ChannelFocus =
+ | { kind: "none" }
+ | { kind: "site"; siteId: string } // live membership: a channel added to the site joins
+ | { kind: "channels"; slugs: string[] };
+
+export type ChannelPriority = {
+ focus: ChannelFocus;
+ // Only channels that differ from the default appear. Absent slug = normal, no rank.
+ channels: Record<string, { tier: ChannelTier; rank?: number }>;
+};
+```
+
+- `focus: {kind:"site"}` is the first-class answer to *"focus = the channels of site X"*, and
+ it is resolved at compile time against `transcripts/sites/<id>/site.json`'s `channels[]`, so
+ it tracks membership rather than freezing a list. `{kind:"channels"}` backs "Focus these".
+- The `focus` tier in `CHANNEL_TIERS` is the compiled *position*, produced by the focus
+ selector; a channel is never stored with `tier: "focus"`. Storing it would give two ways to
+ say the same thing and no way to end a focus in one click.
+- **Absent document = today's behaviour**, byte for byte: no focus, every channel normal, the
+ compiler is never run, the stored trees stand.
+
+`sanitizeChannelPriority(value)` in the same file, called from `getSettings`
+(`common/lib/settings.ts:1312-1436`, beside `merged.syncScheduler = …` at `:1396`) and from
+`saveSettings` (`:1613-1620`). Unknown tiers → `normal`; non-finite ranks dropped; blank slugs
+dropped; `focus.siteId`/`slugs` trimmed and de-duplicated.
+
+### The compiler
+
+```ts
+export function compileLaneRoot(
+ lane: AutoQueueKind,
+ model: ChannelPriority,
+ slugs: readonly string[], // every non-paused channel, from listChannelConfigs
+ focusSlugs: readonly string[],// resolveFocusSlugs(model, siteChannels)
+): AutoQueueGroup
+```
+
+Output, per lane:
+
+```
+root strict
+├─ focus strict — one bare channel leaf per focus slug (omitted when focus is none)
+├─ normal strict — one bare channel leaf per normal slug
+├─ low strict — one bare channel leaf per low slug
+└─ catch-all leaf {type:"all"} ← safety net, last
+```
+
+Within a group: `rank` ascending (unranked last), then slug. Leaf ids are
+`prio-<tier>-<slug>`, group ids `prio-<tier>`, so a compiled tree is recognisable on sight and
+a hand-authored leaf is too. The trailing `{type:"all"}` only ever claims a channel with no
+leaf of its own — i.e. one created since the last compile — so drift is always *safe*
+(bottom priority) and self-heals on the next write. Paused channels never reach it because
+they are gone from the channel list (change 2).
+
+**Cost.** `buildPendingByLeaf` (`autoQueuePolicy.ts:357-428`) is O(leaves × draws × channels)
+in `matchesChannel` string compares: today 10 × 2 × 68 ≈ 1.4 k per tick, compiled 69 × 2 × 68
+≈ 9.4 k. The id-push work is unchanged (each video is claimed once). Against the ~6.5 MB of
+snapshot JSON the same tick parses (memoized at 256 ms, FACTS.md "the snapshot memo"), this is
+noise. `flattenLeaves`/`hasWork` walk ~69 nodes per pick.
+
+## Changes
+
+1. **`common/lib/channelPriority.ts`** — the types above, `defaultChannelPriority()`,
+ `sanitizeChannelPriority()`, `tierOf(model, slug)`, `resolveFocusSlugs(model, siteChannels)`,
+ `compileLaneRoot(...)`, `compileLanes(model, slugs, focusSlugs)` returning
+ `Record<AutoQueueKind, AutoQueueGroup>`, `focusSummary(model, focusSlugs, pendingByLeaf)`
+ for the banner, and `channelPriorityFromLegacy(configs, autoQueue)` for the migration.
+
+2. **`common/controller/autoRunner.ts:288-296`** — `listChannelMeta` drops
+ `tierOf(priority, slug) === "paused"`. One predicate; both the runner loop and
+ `computeLeafPending` inherit it, and the 30 s TTL (`:141`) is the re-evaluation clock.
+ *Nothing else in dispatch changes.*
+
+3. **A new idle reason is NOT added.** A lane whose focus group holds the rest is not idle —
+ it is dispatching focus work. When the whole tree is empty the existing `no-pending`
+ (`autoRunner.ts:197-199`) is still the true answer. The "M channels held" statement is a
+ *display* fact computed from `pendingByLeaf` and belongs in the banner (change 6), not in
+ `AutoRunnerIdleReason`.
+
+4. **Sync** — `common/jobs/syncScheduler.ts`:
+ - `selectDueChannels` takes the model. The `excludeFromSync` skip at `:99` becomes
+ `tierOf(priority, slug) === "paused"`, with the same silent `continue`.
+ - The sort at `:125` becomes **tier rank, then rank, then `overdueMs` descending**:
+ `focus < normal < low`. Most-overdue-first survives *within* a tier, so a focus channel
+ due by a minute outranks a low channel due by a day, and the tick's cap
+ (`editor/app/scheduler/runTick.ts:127-131`) therefore spends its slots on focus first.
+ - `autoSyncEligible` (`:193-197`) follows the same predicate.
+ - `syncAllChannelsAction` (`editor/app/channels/actions.ts:466-472`) swaps its
+ `excludeFromSync` skip for the same one and sorts the candidate list the same way.
+
+5. **`/channels`** — `editor/app/channels/`:
+ - `ChannelSyncToggle.tsx` is deleted and its cell (`ChannelsTable.tsx:429-434`) becomes a
+ `ChannelTierSelect` — a four-option `<select>`, `aria-label={`tier for ${slug}`}`, posting
+ `setChannelTierAction(slug, tier)`. `ChannelBuildToggle` is untouched.
+ - Row selection: checkboxes + a bulk bar, copying `SyncConsole.tsx:29,107,115,169,196-199`
+ and `BulkCadenceBar.tsx:37,41` verbatim rather than inventing an idiom. Bulk actions:
+ **Set tier**, **Focus these**, and a **Focus site: `<id>`** menu built from
+ `listSites()` (no selection needed).
+ - Sort key `sync` (`ChannelsTable.tsx:54-63,129-163`) becomes `tier` (tier order, then
+ rank, then slug). Row dimming (`:406-411`) keys off `tier === "paused"`.
+ - `channelGroupSections.ts:124`'s excluded-from-sync section becomes the Paused section.
+ - **One writer.** `saveChannelPriorityAction` in `editor/app/channels/actions.ts` is the
+ only function that writes `settings.channelPriority`; every control above funnels through
+ it, and it **recompiles the four roots in the same `saveSettings` call** —
+ `autoQueue[lane].root = compileLanes(...)[lane]`, spreading each policy so `held`,
+ `snoozeUntil`, `enabled`, `order` and `maxWorkers` survive (the rule
+ `withGateHeld`/`saveAutoQueueAction:47-80` already state). `createChannel` /
+ `deleteChannel` recompile too, so a new channel gets a real leaf rather than the net.
+
+6. **The focus banner** — `editor/app/channels/components/FocusBanner.tsx`, rendered on
+ `/channels` (`page.tsx`, above the table at `:192`) and on the lane consoles at
+ `editor/app/operations/components/OperationDetail.tsx:127` — between `HowPriorityWorks` and
+ `RunnerOperationView`, i.e. **outside** the `<section data-lane>` that `RunnerOperationView`
+ opens (`:89-93`) and whose contract comment (`:22-36`) reserves `role="status"` and forbids
+ a nested `<section>`. Content: `Focus: <name> (N channels) · <units> pending in this lane ·
+ M channels held`, and an **End focus** button posting `endFocusAction()` (which sets
+ `focus: {kind:"none"}` and recompiles). The numbers come from `computeLeafPending`
+ (`autoRunner.ts:574-662`) — already computed for the status panel, so the banner costs one
+ sum over `counts` keyed `prio-focus-*` and one over the rest. On `/channels` the banner
+ shows the per-lane line only for lanes whose policy is `enabled`.
+
+7. **A held row says why.** `ChannelsTable` renders `Held — focus: <name>` beside the tier of
+ any non-focus channel while a focus is active and the focus set still has pending work in
+ at least one enabled lane. Derived from the same `focusSummary`, not from a new read.
+
+8. **`HowPriorityWorks.tsx`** gains one paragraph: the tree is *generated* from the channel
+ priority model, and `PolicyTreeEditor.tsx` (360 lines) becomes read-only for compiled
+ groups — it keeps editing `enabled`, `maxWorkers`, `order`, `replaceAutoSubs` and any
+ hand-added `bucket`/`operation` leaf, and shows compiled channel leaves with an
+ "edit on /channels" link. It is not deleted: the bucket/operation axis it can express has no
+ equivalent in the priority model.
+
+## Migration
+
+Live data: **15 of 68 channels carry `excludeFromSync: true`** — `community-notes`,
+`angryjoeshow`, `cornbreadman`, `friendofrc`, `hex-headquarters`, `mevsme`, `omnivods-odysee`,
+`exclusively-games`, `rcflightschool`, `rcspotlight`, `teamrcn`,
+`the-incredible-salt-mine`, `steven-crowder`, `midwestly`, `redbar` — and **zero** carry
+`excludeFromBuild`. One of the 15, `omnivods-odysee`, is also the 8th ranked leaf of the live
+download tree: today it is excluded from sync yet still drawn by the download lane. **Paused
+wins** — that is the whole point of a unified model — so migrating it is a real behaviour
+change for that one channel and must be named in the commit. `community-notes` is a jeralyzer
+channel, so a jeralyzer focus will not resurrect it either: paused is applied before the tree
+is consulted.
+
+- **Pure function**, `channelPriorityFromLegacy(configs, autoQueue): ChannelPriority`, in
+ `common/lib/channelPriority.ts`, in the `laneMigration.ts` style (no I/O, idempotent over its
+ own output, asserted through `sanitizeChannelPriority`):
+ - `config.excludeFromSync === true` → `{tier: "paused"}`, checked **first**, so a paused
+ channel that also has a lane leaf (`omnivods-odysee`) is paused and keeps no rank.
+ - A channel named by a bare channel leaf in **either** the download or the transcription root
+ → `{tier: "normal", rank: <the lower of its two leaf indices>}`. Channels ranked in one
+ lane only keep that lane's index.
+ - Everything else → absent (normal, unranked).
+ - `focus: {kind: "none"}`.
+- **It is NOT run from `getSettings`.** That function is synchronous and reads one file; the
+ migration needs 68 `config.json` reads. It is a one-shot script,
+ `common/bin/migrate-channel-priority.ts`, run offline with `tsx` (never a second editor), that
+ reads the configs and `settings.json`, writes `channelPriority`, recompiles the four roots and
+ prints a diff. Idempotent: a second run is a no-op because the document already exists.
+- **The collapse is a behaviour change and must be measured.** Six channels are ranked in one
+ lane only, and the three Quartering channels are in different orders in the two lanes; after
+ the migration both lanes get one order. Gate it with `plans/tools/phase1-numbers.ts`
+ before/after over the live corpus, the way every Phase 1 slice was gated — the `*_leaves`
+ lines will move by construction, so the number that must not move is each lane's total
+ pending count.
+- `excludeFromSync` stays in the type and the sanitizer as the migration's **input only**,
+ exactly as the four retired pause fields did (FACTS.md, slice 1.4), and is deleted with its
+ sanitizer, its action (`actions.ts:411-425`), its toggle and `channel-sync-toggle.spec.ts` in
+ the last slice.
+
+## Tests
+
+**Unit** — `common/lib/channelPriority.test.ts`, run by
+`pnpm --filter yt-dlp-transcript-common test` (`common/package.json` glob covers
+`lib/*.test.ts`):
+1. `sanitizeChannelPriority`: unknown tier → normal; junk rank dropped; blank/dup slugs;
+ absent document → `{focus:{kind:"none"}, channels:{}}`; idempotent over its own output.
+2. `resolveFocusSlugs`: site focus resolves through a site's `channels[]`; an unknown siteId
+ resolves to `[]` (and therefore compiles **no** focus group — a typo must not hold the whole
+ corpus); channel focus keeps order and drops unknown slugs.
+3. `compileLaneRoot`: group order focus → normal → low → catch-all-last; paused slugs never
+ appear; rank ordering with unranked tail; ids are `prio-*`; an empty focus emits no focus
+ group; the output survives `sanitizeAutoQueue` unchanged.
+4. **The focus/hold/done transition, asserted through the real engine**: build two
+ `ChannelWork` objects, compile a tree with channel A focused, and drive `buildPendingByLeaf`
+ + `selectNextWork` — every pick is A's while A has work; the first pick after A's list is
+ drained is B's; re-adding an id to A's bucket makes the next pick A's again. This is the
+ whole design in one test. It lives in **`common/jobs/channelPriorityCompile.test.ts`**, not
+ in `lib/`, because it must import `jobs/autoQueuePolicy` and `architecture.test.ts` forbids
+ `lib/ → jobs/` — the same reason `laneMigration.test.ts` sits in `jobs/`.
+5. `channelPriorityFromLegacy`: the live shape (9+9 leaves, 6 one-lane channels, 3 reordered);
+ an `excludeFromSync` config; a file with the document already present (untouched).
+6. Sync ordering: `selectDueChannels` with one focus channel 1 min overdue and one low channel
+ 1 day overdue returns the focus channel first; a paused channel is never due.
+
+**e2e** — from a `git worktree` with a composed fixture site in its `export/public`, detached
+behind the queue lock (`setsid nohup`, ~25 min), never in the primary checkout while
+`editor/content` is a symlink (STATE.md).
+- `channel-priority.spec.ts` (new): the tier `<select>` persists and re-renders; select two
+ rows → **Focus these** → the banner appears on `/channels` with "2 channels"; **End focus**
+ clears it; a `paused` channel's row dims and its Build toggle still works.
+- `channel-sync-toggle.spec.ts` is deleted in the last slice; its "excluded from Sync all"
+ assertion moves onto the paused tier.
+- Focus hold/release in a runner: `two-slow-channels` (`slow-a`, `slow-b`) is the **only**
+ multi-channel fixture corpus, so it is the fixture. Arm the digest lane (the lane the
+ existing `lane-runner.spec.ts` drives through the Ollama stub, and the one whose `held` is
+ observable — transcription's is not, by design), focus `slow-a`, assert every dispatched unit
+ is `slow-a`'s and the banner says "1 channel held", then exhaust `slow-a` and assert the next
+ unit is `slow-b`'s.
+- **Focus-by-site needs a fixture edit**: `editor/e2e/fixtures/sites/testsite/site.json` names
+ `test-channel`, which is not a slug in any fixture corpus. Add `slow-a` to that site's
+ `channels[]` so **Focus site: testsite** has something to resolve.
+
+**Gates per slice**: `pnpm --filter yt-dlp-transcript-common exec tsc --noEmit`,
+`pnpm --filter yt-dlp-transcript-editor exec tsc --noEmit`,
+`pnpm --filter yt-dlp-transcript-common test` (includes `architecture.test.ts` — **no ALLOWED
+entry may be added**), and the named spec files.
+
+## Slices
+
+**S0 — the contract (lands first, small, blocks everything).** Branch `channel-priority/s0`.
+Files: `common/lib/channelPriority.ts` (all types, sanitizer, resolver, compiler, legacy
+function — the compiler may return a placeholder only if fully typed), the sanitizer wired into
+`getSettings` (`settings.ts:~1396`) and `saveSettings` (`:1613-1620`), `SiteSettings` gains
+`channelPriority`, plus **empty-but-exported stubs** for the files S3/S4 import:
+`editor/app/channels/components/ChannelTierSelect.tsx` and
+`editor/app/channels/components/FocusBanner.tsx`. Tests 1-3 and 5. One commit. Nothing reads the
+model yet, so the live tree is untouched and no number moves.
+
+**S1 — dispatch.** Branch `channel-priority/s1`. Only `common/controller/autoRunner.ts`
+(`listChannelMeta` paused filter) + `common/jobs/channelPriorityCompile.test.ts` (test 4).
+
+**S2 — sync.** Branch `channel-priority/s2`. Only `common/jobs/syncScheduler.ts` (`:99`, `:125`,
+`:193-197`) + test 6.
+
+**S3 — `/channels`.** Branch `channel-priority/s3`. `editor/app/channels/page.tsx`,
+`components/ChannelsTable.tsx`, `ChannelTierSelect.tsx`, a `ChannelBulkBar.tsx`,
+`lib/channelGroupSections.ts`, and `actions.ts` gaining `saveChannelPriorityAction` /
+`setChannelTierAction` / `endFocusAction` (the recompile lives here). `channel-priority.spec.ts`.
+
+**S4 — the banner on the lane consoles.** Branch `channel-priority/s4`.
+`FocusBanner.tsx` (filled in), `editor/app/operations/components/OperationDetail.tsx:127`,
+`HowPriorityWorks.tsx`, `PolicyTreeEditor.tsx` read-only mode. Reads `focusSummary` from S0 and
+renders nothing when `focus.kind === "none"`, so it is green before S3 exists.
+
+**S5 — migration and deletion (lands last).** Branch `channel-priority/s5`.
+`common/bin/migrate-channel-priority.ts`, deletion of `excludeFromSync` from
+`channelConfig.ts:113,278-280`, `actions.ts:411-425`, `ChannelSyncToggle.tsx`,
+`syncScheduler.ts`'s legacy read, `fetchPosts.ts:180`'s comment, and
+`channel-sync-toggle.spec.ts`. The before/after numbers run.
+
+**Graph.** `S0 → {S1, S2, S3, S4} → S5`. S1, S2, S3 and S4 touch **disjoint file sets** and run
+fully in parallel on separate branches once S0 merges; S5 waits on S3 (both edit
+`editor/app/channels/actions.ts`) and on S2 (both edit `syncScheduler.ts`'s skip). Hotspots and
+how they are avoided: **`common/lib/settings.ts`** is touched by S0 alone; **`actions.ts`** by
+S3 then S5, never concurrently; **`syncScheduler.ts`** by S2 then S5; **`autoRunner.ts`** by S1
+alone; **`ChannelsTable.tsx`** by S3 alone; the two stub components exist from S0 so S3 and S4
+each edit one file the other does not. e2e cannot run in parallel regardless — one machine-wide
+queue lock, ~25 min per suite — so the four branches should each run only their own new spec
+plus `channels*.spec.ts`, and the full suite runs once at the merge.
+
+## Out of scope
+
+Per-lane priority overrides; drag-and-drop ordering; time-boxed focus ("focus until Friday");
+auto-ending a focus when its work hits zero (the banner reports it, the operator ends it);
+`excludeFromBuild` and `excludeFromCleanup`; the site-membership editor; any change to
+`operationBatch`, `laneLimit`, `pauseGates`, the `held` keys or `.auto-queue/state.json`.
+
+## Open questions for the operator
+
+1. **The two live trees disagree on six channels and on the Quartering ordering.** Collapse to
+ one order (recommended — it is the point of the feature), or keep a per-lane `rank`?
+2. **Does Paused stop a *manual* run?** Recommended: **no**. Paused gates the sync scheduler,
+ *Sync all*, and all four auto lanes; the per-video and per-channel Run buttons
+ (`runDigestChannelJob`, `runBackfillChannelJob`, the pipeline actions) still work, with a
+ "Paused — automatic work is off for this channel" badge on the channel page.
+3. **Are all 15 `excludeFromSync` channels really meant to be Paused everywhere?** Under the
+ new model they stop being downloaded and transcribed too, not just synced. If some of them
+ were only meant to stop *syncing* while still finishing a backlog, they want `low`, not
+ `paused`, and the migration needs that list.
+4. **Do the 5 channels in no site belong in a tier by default?** They are normal today; a
+ site-scoped focus will hold them like any other non-focus channel.
+5. **Should a Focus also raise the lane's `enabled`?** Recommended: no — focusing must not
+ start a stopped lane, for the same reason `saveAutoQueueAction` refuses to unhold one.
+
+## Roadmap placement — a recommendation, not applied here
+
+**Run it as a standalone plan sequenced as one-core "Phase 1.5": after
+`relocate-channel-media.md`, before Phase 2 (`ArchiveReader`), and only once
+`one-core/phase-1` merges.**
+
+- It must come **after** `relocate-channel-media.md`: `/home` is at 100 % with 6.9 G free, that
+ work is already being refreshed in parallel, and focusing a lane means *more* downloading, not
+ less. Prioritising Jeralyzer onto a full disk fails at the disk gate.
+- It must come **after** the `one-core/phase-1` merge, not beside it: this plan rewrites
+ `autoQueue[lane].root`, which is the artifact slice 1.3's migration produces and slice 1.4's
+ writers spread. Two unmerged branches editing those trees is the one merge conflict worth
+ paying to avoid.
+- It should come **before** Phase 2: Phase 2 is the read contract (`ArchiveReader`) and touches
+ none of `autoQueue`, `syncScheduler` or `/channels`. The two are independent, and this one has
+ an operator waiting on it.
+- **Standalone, not a phase of one-core.** One-core's phases each collapse one concept into the
+ core and delete code; this one *adds* a model and a UI surface, carries its own live-data
+ migration and its own operator gate. But it inherits one-core Phase 1's invariants verbatim
+ (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.
diff --git a/plans/one-core.md b/plans/one-core.md
@@ -250,6 +250,14 @@ guards so an unmounted drive never reads as "nothing downloaded". It touches dis
(`streamCommand`, `autoRunner`, `operationBatch`) but changes no dispatch semantics, and it is
independent of the contract work below.
+The second interlude item is [`channel-priority.md`](channel-priority.md): one ordered-tier
+channel priority model (focus / normal / low / paused, a focus by site or by list) that replaces
+`excludeFromSync`/`excludeFromBuild` on `/channels` and COMPILES to the four `autoQueue[lane].root`
+trees, with paused as one filter in `listChannelMeta`. Standalone plan, sequenced as Phase 1.5:
+after relocate (a focus means more downloading, onto a disk that must have room), before Phase 2
+(which touches none of `autoQueue`, `syncScheduler` or `/channels`), branched off this branch's tip
+so it never edits the lane trees beside Phase 1.
+
### Phase 2 — The contract: one `ArchiveReader` (3 slices)
1. **`common/lib/archive/`**: `contract.ts` (re-exports the `CONTRACT` object from phase 0