commit f21748d52c92bf9d9b256ffabb5acd32677b3d50
parent 635a3dc13fdf02f5ba3eb1a10de4eb2cd0089bcd
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Fri, 28 Aug 2026 15:32:29 -0400
plans: slice 6 planned
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Diffstat:
1 file changed, 575 insertions(+), 0 deletions(-)
diff --git a/plans/editor-ia-slice-6.md b/plans/editor-ia-slice-6.md
@@ -0,0 +1,575 @@
+# Editor IA slice 6 — the video page: one panel per operation
+
+## Context
+
+**Verified read-only against `ce2ebd9` (clean) on 2026-08-28.** Slice 6 is the IA doc's bullet
+(`plans/editor-operations-ia.md:151-156`): `DigestPanel.tsx` generalizes to an `OperationPanel`
+built per enabled kind from `readVideoFiles()`; `hasDigest()` and `hasAttribution()` go;
+attribution and diarization get a per-video view for free; the "Open in umtool" link
+(`VideoPanel.tsx:219-231`, `data-umtool-link`) stays above the panels. The vision test (`:43-45`):
+a new operation is a registry entry and appears on the video page without a new page.
+
+**Decided (operator, 2026-08-28):** (a) panels are the four registry operations (`OPERATIONS`,
+`operations.ts:1498-1510`), shown when `op.enabled(settings)` OR one of `op.outputs` is in the
+listing; the three external operations (download / transcode / transcription) already have
+their per-video surface — the `PipelineStageCard`s inside `VideoPanel.tsx` (`:257-358`) — and
+get **no** panel. (b) State is `Operation.state(probe)`, never re-derived. (c) One generic
+frame, one body per `settingsBlock`, the digest body byte-identical in its labels. (d) One
+`stateCopy` table; the `digest.spec.ts` pill contract survives verbatim. (e) The speaker
+operations get a per-video **Run** via `ids` threaded through the backfill channel job.
+(f) `hasAttribution` deleted; `hasDigest` becomes a private helper of the channel count.
+(g) The reader lives in `common/controller/videoOperations.ts`; the `Operation` type is not
+changed.
+
+**What the census found (corrects the bullet in six places):**
+
+1. **`hasDigest` is not a video-page helper.** Its one caller is
+ `common/controller/channels.ts:94`, inside `countDataFiles` (`:67`) under
+ `listChannelStatsFromDisk` (`:214`) — the batch ground-truth walk. It is identity-blind on
+ purpose (`digest-server.ts:84-90`: "is there a digest at all", never "is it current") and
+ `channelProjection.test.ts:66-67` pins that a record with a non-empty section counts. It is
+ defined at `digest-server.ts:91-98`, not `:89`. Moving it into `channels.ts` as a private
+ function and deleting the export is the whole of "deletes `hasDigest()`".
+2. **`hasAttribution` was already dead** (`attribution-server.ts:36-38`; zero callers in
+ `common editor export homepage umtool mcp`). **`hasDiarization` is NOT dead** and stays:
+ `cleanAudioFromTranscribed.ts:125` (the audio-deletion guard), `diarizeOne.ts:185`, and
+ `diarization.test.ts:57-92`. (`files.hasDiarization` on `VideoFiles` is a listing flag, a
+ different thing, untouched.)
+3. **The page duplicates digest `state()`.** `page.tsx:155-184` calls `resolveDigestTarget`
+ with `lane: "local"` and folds `isSectionFresh` + the `derivedFrom` rule per section; the
+ registry's `digest.state()` (`operations.ts:1104-1185`) does the same fold at `:1153` and
+ `:1176-1178`, and its `resolveTarget` (`:1096-1103`) calls the same resolver with no lane,
+ which `resolveDigestChunking` (`digestTarget.ts:69-79`) resolves to `localAppId` — the same
+ identity. Dropping the page's copy changes no displayed value. The per-section
+ `{present, fresh, provenance}` detail (`DigestPanel.tsx:38-43`) stays as the digest body's
+ `sections`, and this slice makes it the SAME fold: a `digestSectionStates()` helper in
+ `common/lib/digest.ts` that both `state()` and the body use.
+4. **`readVideoFiles` has never been called from `editor/`.** The page's own `loadVideoDir`
+ (`page.tsx:57-77`) is a `readdir` + per-file `stat` for size/mtime — the Files card needs
+ those, so it stays; `readVideoFiles` is a second `readdir` (~free) that the registry's
+ `state()` requires as its probe.
+5. **`ids` already exists on the batch and not on the job.** `runBackfillBatch` takes
+ `ids?: string[]` (`backfillBatch.ts:99`, intersected with disk at `:290-291`);
+ `BackfillChannelJobOptions` (`operationJobs.ts:60-75`) does not, and `countBackfillWork`
+ (`backfillBatch.ts:869-926`) walks every dir with no filter — so a per-video job would size
+ its progress bar to the whole channel. `editor/app/jobs/jobReplayRegistry.ts:281-284` reads
+ `p.kindIds` only. `digestBucketAction` (`digestActions.ts:83-135`) is the precedent for an
+ id-scoped job on the other lane. The job's summary line (`operationJobs.ts:120-121`) is
+ `Backfill <slug>: N done, …` — what the new e2e waits on.
+6. **The e2e surface.** `digest.spec.ts:569-680` are the three panel tests; every selector
+ they use is in the aria table below. `video-page.spec.ts` has 20 tests, all on `VideoPanel`
+ labels; its fixture video has metadata + `transcript.en.vtt` and no `transcript.cues.json`,
+ so digest classifies it `deferred` there (nothing asserts the pill in that spec today). No
+ fixture on disk carries an AI sidecar. `attribution.spec.ts` `seedChannel()` (`:137-148`,
+ writes `diarization.json` for `attrvid0001` by default) + `attributionSettings()`
+ (`:101-135`, **diarization capture off**) is the seeding pattern — and it is exactly the
+ "shown because its output is present" case for the diarization panel. `runBackfill()`
+ (`:150-161`) shows the stub-backed run works in that spec.
+
+**Hazards carried through every commit:**
+
+- **Client graph.** `common/lib/operations.ts` imports `digest-server`, `diarization-server`,
+ `attribution-server`, `videoStatus` — all `node:fs`. Every `"use client"` file in this
+ slice imports from it **as `import type` only** (`OperationClassification`,
+ `OperationSettingsBlock`, `OperationGroup`) — the `pauseControl.tsx` lesson. `common/lib/
+ digest.ts`, `diarization.ts`, `attribution.ts` have **no imports at all** (verified) and are
+ safe for types and pure values. `deferredHint` and `dependsOn` labels reach the client as
+ **props**, never by reading the registry.
+- **`"use server"` files must not re-export types** (`digestActions.ts:37-48` explains: the
+ transform emits a runtime binding). The new action file declares nothing but functions.
+- **`StreamActionLog` contract** (`common/components/StreamActionLog.tsx`): `accessibleName =
+ label ?? buttonLabel` (`:46`); the log is `role="log" aria-label="${accessibleName} output"`
+ (`:238`); cancel `"Cancel ${accessibleName}"`; error `"${accessibleName} error"`; queue
+ banner `"${accessibleName} queue status"`; the button is disabled until hydrated; on
+ completion it calls `router.refresh()` (`:163`), which re-renders the pill.
+- **`resolveDigestTarget` reads `getSettings()` from disk** (`digestTarget.ts:69, 88`), not
+ the probe's `settings`, and `getPaths()` memoizes. The reader's unit test sets
+ `TRANSCRIPTS_DIR` and `SETTINGS_FILE` at module top before any import
+ (`laneForOperation.test.ts:20-29` is the pattern; `readDigestContext` tolerates a missing
+ note).
+- **The guard** `noCorpusWalkInRenderPaths.test.ts` bans `listChannelStatsFromDisk |
+ buildBackfillSweepPlan | buildDigestSweepPlan` textually under `editor/app`, comments
+ included. Nothing new mentions them. (`countBackfillWork` runs inside the job `fn`, as
+ `backfillActions.ts` already does — not a render path.)
+- **`revalidatePath` in `onDone`** fires at job end outside a request; `backfillActions.ts:33`
+ and `digestActions.ts:76` already do this.
+- **"held — " + `deferredHint`.** Both hints are plural fragments completing "N videos are …"
+ (`operations.ts:397-399`): diarization ends "…to include **them**" (`:641-642`), digest
+ "…to make **them** digestable" (`:1064-1065`). After "held — " they read acceptably for one
+ video. Keep them verbatim — one copy, `operations.test.ts:1163-1177` pins the digest one —
+ and say so in the comment. Do not add a singular copy.
+- **A shown-but-disabled operation** (finding 6) would read "stale — regenerating would
+ replace this" beside nothing that can regenerate it. The frame adds a muted
+ `<span aria-label="${op.id} off">switched off</span>` beside the pill when `!view.enabled`,
+ and the speaker body's Run button is not drawn for it.
+
+## Step 0 — the plan on disk
+
+Write this file verbatim to `plans/editor-ia-slice-6.md`; commit alone: `plans: slice 6 planned`.
+
+## Order: five commits after the plan
+
+1. **`common: a video's operations are read once, from the registry`** — `videoOperations.ts`
+ + test; `digestSectionStates` in `digest.ts` and `digest.state()` on it; `hasDigest` moves
+ into `channels.ts`; `hasAttribution` deleted.
+2. **`backfill: a channel job can be scoped to video ids`** — `ids` through
+ `runBackfillChannelJob` → `countBackfillWork` → `runBackfillBatch`; in `spec.params`; the
+ replay handler and `backfillChannelAction` carry it; the ids test.
+3. **`video page: one panel per operation`** — `OperationPanel` frame, `DigestBody` (renamed
+ `DigestPanel`), `SpeakerBodies`, `operationState.ts` + unit test, the page on
+ `inspectVideoOperations`; `isSectionFresh` and `resolveDigestTarget` leave `page.tsx`.
+4. **`video page: a speaker operation runs for one video`** — `operationActions.ts`, the Run
+ button in the speaker bodies, two new e2e in `attribution.spec.ts`, one assertion in
+ `video-page.spec.ts`.
+5. **`plans: slice 6 shipped, and the docs say so`** — CHANGELOG, IA doc, STATE, FACTS, memory.
+
+Gates after each of 1–4: `pnpm -C <pkg> exec tsc --noEmit` for `common editor export homepage
+umtool mcp`; `pnpm -C common test` (867 at plan time — re-measure before commit 1 — plus the
+new file's tests); `pnpm -C editor exec tsx --test "app/**/*.test.ts"` (90 + the
+`operationState` tests). e2e once after commit 5, detached (memory `e2e-run-detached`).
+
+---
+
+## Commit 1 — `common: a video's operations are read once, from the registry`
+
+**New helper in `common/lib/digest.ts`, beside `isSectionFresh` (`:513`):**
+
+```ts
+// One row per section kind the current settings ask for. THE SAME FOLD the digest
+// operation's state() counts — a section is fresh here iff state() counts it fresh —
+// so the per-video panel and the channel's work list cannot disagree about one section.
+// A SHARED digest (derivedFrom) is fresh for the receiving video as long as it still
+// points at its canonical member (isSharedFrom's contract; state() returns present on
+// the same condition).
+export type DigestSectionState = {
+ kind: DigestSectionKind;
+ present: boolean;
+ fresh: boolean;
+ provenance?: DigestProvenance;
+};
+export function digestSectionStates(
+ record: DigestRecord | null,
+ sections: readonly DigestSectionKind[],
+ target: DigestFreshnessTarget,
+): DigestSectionState[];
+```
+
+`operations.ts:1176-1178` becomes `digestSectionStates(record, sections, freshness).filter((s) => s.fresh).length`
+(the `derivedFrom` early return at `:1153` stays; behaviour identical — `operations.test.ts`
+digest cases stay green). `DigestSectionState` is deleted from `DigestPanel.tsx:38-43` in
+commit 3 and imported from here.
+
+**New `common/controller/videoOperations.ts`:**
+
+```ts
+import type { OperationClassification, OperationGroup, OperationSettingsBlock } from "../lib/operations";
+
+export type VideoOperationView = {
+ id: string;
+ label: string;
+ group: OperationGroup;
+ settingsBlock?: OperationSettingsBlock;
+ state: OperationClassification; // Operation.state(probe), never re-derived
+ enabled: boolean; // op.enabled(settings)
+ outputs: { name: string; present: boolean }[]; // op.outputs × files.entries
+ deferredHint?: string;
+ dependsOn: { id: string; label: string }[]; // operationLabel(depId), operations.ts:1416
+ // The resolved target, erased exactly as Operation.resolveTarget erases it. The one
+ // consumer that narrows it is the digest body (it needs {target, sections} for its
+ // per-section rows). Never crosses to a client.
+ target: unknown;
+};
+
+// ONE readVideoFiles per page, ONE resolveTarget + state per operation. Walks OPERATIONS
+// in registry order; filters nothing — `shownOnVideoPage` is the rule.
+export async function inspectVideoOperations(opts: {
+ paths: Paths; channelSlug: string; videoId: string; settings: SiteSettings;
+}): Promise<VideoOperationView[]>;
+
+// Shown when the feature is on OR its output is on disk: a sidecar captured before the
+// feature was switched off is still a thing the operator needs to see.
+export function shownOnVideoPage(view: VideoOperationView): boolean;
+
+// Group order first (OPERATION_GROUP_ORDER, operations.ts:332-337: media, transcript,
+// digest, speakers — the order the channel stages and /channels columns use), registry
+// order within a group: Digest, then the three speaker operations. Pure.
+export function orderForVideoPage(views: VideoOperationView[]): VideoOperationView[];
+```
+
+Probe per operation, verbatim from decision (b):
+`{ videoDir, videoId, files: await readVideoFiles(videoDir, { checkUntranscribable: true }), target: await op.resolveTarget({ settings, paths, channelSlug }), settings }`
+— the shape `countBackfillWork` builds at `backfillBatch.ts:886-916` and
+`operations.test.ts:176-186` drives. Header comment: the external operations are deliberately
+absent (their per-video surface is `VideoPanel`'s stage cards); this reader is pure over the
+registry and is the reason a fifth operation needs no page.
+
+**`common/controller/videoOperations.test.ts`** (node:test; env seam at module top as
+`laneForOperation.test.ts:20-29`; fixtures as `operations.test.ts:107-168` — a temp
+`ROOT/channels/<slug>/data/<id>` with `transcript.json`, optional `diarization.json`,
+`attribution.json`, `ai-digest.json` + `transcript.cues.json`): one view per registry entry,
+in `OPERATIONS` order; a diarized video under diarization-enabled settings reads
+`diarization: present`, `attribution-diarized: missing`, `attribution-text: missing`; an
+undiarized one reads `attribution-diarized: blocked` with `dependsOn[0].label ===
+"Speaker diarization"`; `outputs[0].present` tracks the listing; `shownOnVideoPage` is false
+for a disabled operation with no output and true for a disabled one whose sidecar exists;
+`orderForVideoPage` puts `digest` first; an untranscribed dir reads `not-applicable` on the
+speaker operations and `blocked` on digest; a video with no `cues.json` reads digest
+`deferred` with `deferredHint` populated.
+
+**`hasDigest` moves.** `common/controller/channels.ts:15` imports `loadDigest` instead; a
+private `async function hasDigestWithItems(dir: string): Promise<boolean>` above
+`countDataFiles` (`:67`) carries the identity-blind comment from `digest-server.ts:84-90`
+verbatim and the body from `:91-98`; `:94` calls it. Delete `digest-server.ts:84-98`.
+`channelProjection.test.ts:66-67` stays green (its comment may say "the channel count").
+
+**`hasAttribution` deleted:** `attribution-server.ts:36-38`. Its `:14-20` comment cites
+`hasDiarization` — still true, leave it.
+
+---
+
+## Commit 2 — `backfill: a channel job can be scoped to video ids`
+
+- `common/controller/operationJobs.ts:60-75` `BackfillChannelJobOptions` gains
+ `ids?: string[]` — comment: "Only these videos (intersected with disk by the batch). A stage
+ card passes none; the video page passes one. In `spec.params` so a replay stays scoped."
+ `:80` destructures it; `:93` → `params: { kindIds, queueKey: opts.queueKey, ids }`;
+ `:96` → `countBackfillWork(paths, channelSlug, kindIds, ids)`; `:97-110` passes `ids`.
+ `runOperationChannelJob` is unchanged — the dispatcher stays channel-scoped.
+- `common/controller/backfillBatch.ts:869-878` `countBackfillWork(paths, channelSlug, kindIds?,
+ ids?)`: filter the dir list to `ids` when given, before the walk. Comment: "the progress
+ target must count what THIS run will pull, and an ids-scoped run pulls only those."
+ `backfillSweep.ts:168` (three args) is unaffected.
+- `editor/app/channels/[slug]/backfillActions.ts:25-35` `backfillChannelAction(slug, queueKey?,
+ kindIds?, ids?)` passes `ids` through.
+- `editor/app/jobs/jobReplayRegistry.ts:281-284`: parse `ids` exactly as `kindIds`
+ (`Array.isArray(p.ids) ? p.ids.filter((k): k is string => typeof k === "string") : undefined`)
+ and pass it as the fourth argument. Comment: a replayed per-video run stays per-video.
+- Test, in `videoOperations.test.ts` (it owns the settings seam; `backfillBatch.test.ts` is
+ pure and has none): `countBackfillWork(paths, slug, ["attribution-text"], [idB])` over two
+ transcribed dirs returns `reachable: 1`.
+
+---
+
+## Commit 3 — `video page: one panel per operation`
+
+All under `editor/app/channels/[slug]/videos/[id]/`.
+
+**`components/operationState.ts`** (no directive; pure; imported by client and server):
+
+```ts
+import type { OperationClassification, OperationGroup, OperationSettingsBlock } from "yt-dlp-transcript-common/lib/operations";
+
+// The serializable half of VideoOperationView — what crosses to the client. No `target`.
+export type OperationPanelView = {
+ id: string; label: string; group: OperationGroup; settingsBlock?: OperationSettingsBlock;
+ state: OperationClassification; enabled: boolean;
+ outputs: { name: string; present: boolean }[];
+ deferredHint?: string; dependsOn: { id: string; label: string }[];
+};
+
+// THE ONE TABLE. The present / stale / missing rows are digest.spec.ts's contract
+// (`:580` "not generated", `:585,620` "current", `:628` contains "stale") and must not move.
+export function stateCopy(view: OperationPanelView): string;
+export function stateTone(state: OperationClassification): "success" | "warning" | "muted";
+```
+
+| `state` | pill text | tone |
+|---|---|---|
+| `present` | `current` | success |
+| `stale` | `stale — regenerating would replace this` | warning |
+| `partial` | `partly stale — regenerating would replace the stale sections` | warning |
+| `missing` | `not generated` | muted |
+| `missing-input` | `input gone — needs media` | warning |
+| `deferred` | `held — ` + `deferredHint` (verbatim; `held` alone if none) | muted |
+| `blocked` | `waiting on ` + `joinLabels(dependsOn.map(d => d.label))` ("A", "A and B" — copy `OperationDetail.tsx:284-287`) | muted |
+| `not-applicable` | `not applicable` | muted |
+
+`partial` contains "stale", so `digest.spec.ts:628` (`toContainText("stale")`) passes whether
+the config change leaves the video `stale` or `partial` — say so in the test comment. Tone
+classes: `text-success` / `text-warning` / `text-muted-foreground`, as `DigestPanel.tsx:84-90`.
+
+**`components/operationState.test.ts`** (node:test, `app/**/*.test.ts` glob): the eight rows;
+`deferred` with and without a hint; `blocked` with one and two dependencies; the three digest
+strings asserted as literals with a comment naming `digest.spec.ts`.
+
+**`components/OperationPanel.tsx`** (`"use client"`; type imports only):
+
+```tsx
+export type OperationPanelBody =
+ | { block: "digest"; data: DigestBodyData }
+ | { block: "diarization"; data: DiarizationBodyData }
+ | { block: "attribution"; data: AttributionBodyData }
+ | { block: undefined }; // an operation with no settings block: outputs list only
+
+export function OperationPanel({ slug, videoId, view, body }: {
+ slug: string; videoId: string; view: OperationPanelView; body: OperationPanelBody;
+})
+```
+
+Frame: `<section aria-label={`${view.id} panel`} className="flex flex-col gap-4 rounded border border-border p-3">`,
+header row with `<h2 className="text-lg font-semibold">{view.label}</h2>`, the pill
+`<span aria-label={`${view.id} freshness`}>{stateCopy(view)}</span>`, and, when
+`!view.enabled`, `<span aria-label={`${view.id} off`} className="text-xs text-muted-foreground">switched off</span>`.
+Body: `switch (body.block)` — **exhaustive**, the `settingsFormFor` precedent
+(`operations/[id]/page.tsx:71-110`). The `undefined` arm renders
+`<ul aria-label={`${view.id} outputs`}>` of `outputs` with present / absent — that arm is the
+vision test's guarantee for a registered operation with no body yet.
+
+**`components/DigestBody.tsx`** = `git mv DigestPanel.tsx DigestBody.tsx`, then: delete the
+`<section>`/`<h2>`/pill (`:76-98`) — the frame owns them; export `DigestBody` (was
+`DigestPanel`) and `DigestBodyData` (was `DigestPanelData`, `:45-60`, same fields; `sections:
+DigestSectionState[]` now imported from `common/lib/digest`); delete `:38-43`. Everything else
+stays byte-for-byte: `SharedFrom`, `Warnings`, `SectionProvenance`, `Chapters`, `Tags`,
+`OverridesEditor`, the `StreamActionLog` block (`:127-150`, `digestBucketAction`, "Regenerate
+digest", the lane select). Header comment (`:3-17`) is kept and gains one line: the frame is
+`OperationPanel`; this is the digest's body.
+
+**`components/SpeakerBodies.tsx`** (`"use client"`; `import type { DiarizationEngine }` from
+`common/lib/diarization`, `import type { AttributionProvenance }` from `common/lib/attribution`):
+
+```ts
+export type DiarizationBodyData = {
+ record: { generatedAt: string; audioSeconds?: number; durationMs?: number; speakers: number;
+ turns: number; engine: DiarizationEngine;
+ windowing?: { windows: number; windowSeconds: number; overlapSeconds: number } } | null;
+};
+export type AttributionBodyData = {
+ record: { generatedAt: string;
+ speakers: { index: number; label: string; cluster?: number; confidence?: number; seconds?: number }[];
+ segments: number; warnings: number; provenance: AttributionProvenance } | null;
+};
+export function DiarizationBody({ slug, videoId, view, data }: …)
+export function AttributionBody({ slug, videoId, view, data }: …)
+```
+
+(Field names above follow `DiarizationRecord` (`diarization.ts:52-82`) and `AttributionRecord`
+(`attribution.ts:91-138`) — verify each against the types and project only what exists.)
+
+`DiarizationBody`: `record === null` → `<p aria-label="diarization empty">No speaker turns have been captured for this video.</p>`;
+else `<dl aria-label="diarization provenance">` with `Row`s (copy `DigestPanel.tsx:323-330`)
+for `engine`, `segmentationModel`, `embeddingModel`, `model`, `version`, `threshold` (each
+only when set), `generated`, `audio` (m:ss), `took` (`durationMs`), `windows` when present;
+then `<p aria-label="diarization summary">{speakers} speakers · {turns} turns</p>`.
+`AttributionBody`: `record === null` → `<p aria-label="attribution empty">No speaker names for this video yet.</p>`;
+else `<dl aria-label="attribution provenance">` for `method`, `engine` (`appId`), `model`
+(the `modelRequested → model` rule from `DigestPanel.tsx:277-284`), `promptVersion`,
+`transcriptSource`, `diarizationGeneratedAt`, `chunks` (`chunksOk of chunks usable`),
+`generated`, `cost`; `<ul aria-label="attribution speakers">` one
+`<li aria-label={`attribution speaker ${index}`}>` per speaker; `<p aria-label="attribution summary">{segments} segments · {warnings} warnings</p>`.
+File header comment: **both attribution operations share `attribution.json`**
+(`operations.ts:789-812`), so `attribution-diarized` and `attribution-text` panels read the
+same record with their own `state()` — the pill differs, the body does not; the
+`provenance.method` row is how a reader tells which lane wrote it. The Run button is commit 4.
+
+**`lib/videoOperationPanels.ts`** (server-only, no directive):
+
+```ts
+export type VideoOperationPanel = { view: OperationPanelView; body: OperationPanelBody };
+export async function loadVideoOperationPanels(opts: {
+ paths: Paths; channelSlug: string; videoId: string; settings: SiteSettings;
+}): Promise<VideoOperationPanel[]>
+```
+
+Calls `inspectVideoOperations`, `.filter(shownOnVideoPage)`, `orderForVideoPage`, and per view
+loads the body by `settingsBlock`: `"digest"` → `loadDigest` + `loadDigestOverrides` (one
+`Promise.all`, as `page.tsx:159-163` today), narrows `view.target as { target:
+DigestFreshnessTarget; sections: DigestSectionKind[] }`, builds `DigestBodyData` with
+`sections: digestSectionStates(record, sections, target)`, `digest: effectiveDigest(record,
+overrides)`, `hasMachineDigest: record !== null`, `hasOverrides`, `note`, `remoteEnabled:
+settings.digest.remoteEnabled`; `"diarization"` → `loadDiarization`, projected;
+`"attribution"` → `loadAttribution` **once, memoized across the two attribution views**;
+`undefined` → `{ block: undefined }`. Strips `target` before returning.
+
+**`page.tsx`:** delete the imports of `loadDigest`, `loadDigestOverrides`, `effectiveDigest`,
+`isSectionFresh`, `resolveDigestTarget`, `DigestPanel`/`DigestPanelData`/`DigestSectionState`
+(`:30-45`); delete `:155-184`; add
+`const panels = await loadVideoOperationPanels({ paths: getPaths(), channelSlug: slug, videoId: id, settings: getSettings() })`
+(keep the `getSettings` import); `:275` becomes
+`{panels.map((p) => <OperationPanel key={p.view.id} slug={slug} videoId={id} view={p.view} body={p.body} />)}`.
+`loadVideoDir`, `loadMeta`, the VTT provenance loop, coverage, `RunningJobsList`, `VideoPanel`
+and its `umtoolUrl` prop (`:254-273`) are untouched. Comment above the map: the external
+operations' per-video surface is the stage cards inside `VideoPanel`; these panels are the
+registry's.
+
+**Aria-label table (old → new; which spec pins it):**
+
+| old (`DigestPanel.tsx`) | new | pinned by |
+|---|---|---|
+| `section[aria-label="digest panel"]` (`:77`) | `"${id} panel"` → `digest panel` | none (kept) |
+| `h2` "AI digest" (`:81`) | `view.label` → **"Digest"** | none (grep: "AI digest" only in `sections.tsx:154` prose) |
+| `"digest freshness"` (`:83`) | `"${id} freshness"` → `digest freshness` | `digest.spec.ts:580,585,620,628` |
+| `"digest shared from"` (`:173`) | unchanged | none |
+| `"digest empty"` (`:105`) | unchanged | `digest.spec.ts:579` |
+| `"digest warnings"` / `"digest warnings empty"` (`:215,202`) | unchanged | `:602,605` |
+| `"digest provenance"` / `"digest section ${kind}"` (`:256,260`) | unchanged | `:592,629` |
+| `"digest chapters"` / `"digest chapters empty"` (`:343,335`) | unchanged | `:588,671` |
+| `"digest tags"` (`:367`) | unchanged | none |
+| "Correct this digest" / `"digest override note"` / "Save corrections" / `role="status"` "Saved." | unchanged | `:648-654` |
+| "Regenerate digest" / `"lane for Regenerate digest"` (`:130-139`) | unchanged | none |
+| — | `diarization panel`, `diarization freshness`, `diarization empty`, `diarization provenance`, `diarization summary`, `diarization off` | new e2e (commit 4) |
+| — | `attribution-diarized panel` / `attribution-text panel`, `… freshness`, `attribution empty`, `attribution provenance`, `attribution speakers`, `attribution speaker ${index}`, `attribution summary` | new e2e (commit 4) |
+| — | `"${id} outputs"` (no-block arm) | unit-tested shape only |
+
+`getByLabel("digest freshness")` is substring-matched; no new label contains that string, and
+`digest empty` / `digest warnings` retain their `exact` guards.
+
+---
+
+## Commit 4 — `video page: a speaker operation runs for one video`
+
+**New `editor/app/channels/[slug]/videos/[id]/operationActions.ts`** (`"use server"`):
+
+```ts
+// Run ONE registry operation over ONE video, from its panel. The speaker lane only:
+// digest keeps digestBucketAction (its own lane, its own guards).
+export async function runOperationForVideoAction(
+ slug: string, videoId: string, operationId: string,
+): Promise<StreamActionResult>
+```
+
+Body: `const lane = backfillLaneOperations(getSettings())` (`operations.ts:1554`); if no entry
+has `id === operationId` → `{ ok: false, error: \`${operationLabel(operationId)} is not switched on, so there is nothing to run — enable it on its Operations page.\` }`
+(a disabled operation's panel does not draw the button; this is the belt); else
+`runBackfillChannelJob({ paths: getPaths(), channelSlug: slug, kindIds: [operationId], ids: [videoId], onDone: () => { revalidatePath(\`/channels/${slug}/videos/${videoId}\`); revalidatePath(\`/channels/${slug}\`); } })`.
+No `queueKey` (the lane's own key, `operationJobs.ts:83`); no `background` (a hand-clicked run
+goes in front of the sweep's — `backfillActions.ts:18`).
+
+**`SpeakerBodies.tsx`:** both bodies render, when `view.enabled`, after the record:
+
+```tsx
+<div className="flex flex-col gap-2 border-t border-border pt-3">
+ <StreamActionLog
+ trigger={() => runOperationForVideoAction(slug, videoId, view.id)}
+ cancelAction={cancelJobAction} // jobs/actions, as DigestPanel.tsx:33
+ buttonLabel={`Run ${view.label}`} // "Run Speaker diarization", "Run Speaker names (from the audio)", "Run Speaker names (from the transcript)"
+ runningLabel="Running…"
+ label={`Run ${view.label}`} // → aria-label "Run <label> output"
+ />
+ <p className="text-xs text-muted-foreground">Runs this one video on the speaker lane, behind nothing — the channel's Speakers stage runs the whole channel.</p>
+</div>
+```
+
+**e2e — `editor/e2e/attribution.spec.ts`, two tests after `:291`, reusing `seedChannel`,
+`attributionSettings`, `dataRel`, `readAttribution`, `pathExists`, `SLOW`, `CHANNEL`,
+`DIARIZED`, `TEXTONLY`:**
+
+1. `"the video page draws one panel per operation, with the registry's own state"`:
+ `await seedChannel(); await writeSettings(attributionSettings());`
+ `goto(/channels/${CHANNEL}/videos/${DIARIZED})`:
+ `getByLabel("diarization panel")` visible (shown because `diarization.json` exists while
+ capture is off) and `getByLabel("diarization off")` visible;
+ `getByLabel("diarization provenance")` contains `fake-diarize`;
+ `getByLabel("diarization summary")` contains `2 speakers` (check `diarizationRecord()`);
+ `getByLabel("attribution-diarized freshness")` has text `not generated`;
+ `getByLabel("attribution-text freshness")` has text `not generated`;
+ `getByLabel("attribution empty")` count `2` (both attribution panels, one shared file);
+ `getByLabel("digest freshness")` has text `not generated` (`writeDigestVideo` writes a
+ fresh `cues.json`, so digest is `missing`, not `deferred`);
+ `getByRole("heading", { name: "Speaker names (from the audio)" })` visible.
+ Then `goto(…/${TEXTONLY})`: `getByLabel("diarization panel")` count `0` (off, no sidecar);
+ `getByLabel("attribution-diarized freshness")` has text `waiting on Speaker diarization`.
+2. `"Run from the video page runs that video and no other"` (`test.setTimeout(SLOW)`):
+ `seedChannel(); writeSettings(attributionSettings());` `goto(…/${TEXTONLY})`;
+ `getByRole("button", { name: "Run Speaker names (from the transcript)", exact: true }).click()`;
+ `expect(getByLabel("Run Speaker names (from the transcript) output")).toContainText("1 done", { timeout: 90_000 })`
+ (the summary line `operationJobs.ts:120-121`);
+ `readAttribution(TEXTONLY).provenance.method === "text-only"`;
+ `pathExists(dataRel(DIARIZED, "attribution.json")) === false` — DIARIZED is reachable for
+ this operation too, so only the `ids` scope kept it untouched;
+ `expect(getByLabel("attribution-text freshness")).toHaveText("current")` (after
+ `router.refresh()`).
+
+**`editor/e2e/video-page.spec.ts`, one assertion appended to the first test (`:11-27`):**
+`await expect(page.getByLabel("digest freshness")).toContainText("held — waiting on a normalized transcript")` —
+the fixture has no `cues.json`; this is the first place the digest's deferred reason is visible
+per video.
+
+---
+
+## Commit 5 — `plans: slice 6 shipped, and the docs say so`
+
+- `editor/CHANGELOG.md` `[Unreleased]` first bullet, house voice (the slice 7 bullet at `:4` is
+ the model): the video page shows one panel per operation — Digest as before, and now
+ Speaker diarization and both Speaker names operations, each with what produced its record,
+ the same state word the channel's counts use (*current*, *stale*, *partly stale*, *not
+ generated*, *held — …*, *waiting on …*), and a **Run** button that runs that one video on
+ the speaker lane; a record whose feature is switched off is still shown, marked *switched
+ off*; the digest panel's heading is **Digest** rather than *AI digest* and nothing else about
+ it moved; a replayed per-video run stays per-video; **nothing on disk changes**.
+- `plans/editor-operations-ia.md:151-156` → SHIPPED with hashes; new "## Slice 6, as shipped"
+ between the Slice 4 (`:324`) and Slice 7 (`:379`) sections, recording: `hasDigest` was the
+ channel count's, not the page's; `hasAttribution` was dead and `hasDiarization` is a cleanup
+ guard and stays; the page re-derived digest `state()` and `digestSectionStates` is now the
+ one fold; the external operations' surface is the stage cards; `ids` on the channel job; the
+ "switched off" case; the vision test now holds for the video page (the no-block arm).
+- `plans/STATE.md`: "Last updated" prepend; "Recommended next" (`:184`) adds a DONE line.
+- `plans/FACTS.md`: `## Verified 2026-08-28 — editor IA slice 6 seams` (this census + "As
+ shipped"), after the slice 7 section; the stale `:2316` line about `DigestPanel.tsx:144`
+ ("metered — disabled in Settings", already fixed by slice 3) corrected.
+- Memory: `ia-slice-6-shipped.md` + a `MEMORY.md` line; amend `ia-slice-7-shipped`'s "next
+ candidates".
+
+---
+
+## Verification
+
+1. After each of commits 1–4: tsc in `common editor export homepage umtool mcp`;
+ `pnpm -C common test` (867 + `videoOperations` tests; re-measure first); editor units
+ (90 + `operationState` tests).
+2. Grep gates after commit 3, over `common/ editor/` excluding `node_modules`/`.next`:
+ - `hasDigest\b` → only `common/components/digestCache.ts` and `PlayerProvider.tsx` (a
+ different function: the published manifest check) and the private `hasDigestWithItems`
+ in `channels.ts`; **0** in `digest-server.ts`.
+ - `hasAttribution` → **0** in code (plans/ prose is commit 5's).
+ - `hasDiarization` → unchanged set (`diarization-server.ts`, `cleanAudioFromTranscribed.ts`,
+ `diarizeOne.ts`, `diarization.test.ts`, comments, and the `VideoFiles` field sites).
+ - `isSectionFresh|resolveDigestTarget|effectiveDigest` in `videos/[id]/page.tsx` → **0**.
+ - `DigestPanel\b|DigestPanelData` → **0** anywhere.
+ - `from "yt-dlp-transcript-common/lib/operations"` in any `"use client"` file under
+ `videos/[id]/components` → every hit is `import type`.
+ - `listChannelStatsFromDisk|buildBackfillSweepPlan|buildDigestSweepPlan` under `editor/app`
+ → 0 (run the guard: `pnpm -C common exec tsx --test controller/noCorpusWalkInRenderPaths.test.ts`).
+ - `"AI digest"` → only `sections.tsx:154`.
+3. e2e once after commit 5, detached (`cd editor && setsid nohup sh -c 'pnpm e2e -- <specs>;
+ echo exit=$?' > $CLAUDE_JOB_DIR/tmp/e2e.log 2>&1 < /dev/null & disown`):
+ `digest.spec.ts video-page.spec.ts attribution.spec.ts diarization.spec.ts backfill.spec.ts
+ do-not-clean.spec.ts truncated-check.spec.ts incomplete-transcript.spec.ts
+ transcript-source.spec.ts whisper-video.spec.ts saved-videos.spec.ts cleanup-holds.spec.ts
+ reconcile.spec.ts undownloaded.spec.ts reconstruct-download-url.spec.ts
+ download-format-guard.spec.ts media-file-abort.spec.ts auto-report-refresh.spec.ts
+ jobs-active-order.spec.ts perf-budget.spec.ts`. Port fallback `PORT=3111 EXPORT_PORT=3110
+ OLLAMA_STUB_PORT=11535`; never kill anything.
+4. **No editor boot against `transcripts/`, nothing written under it.**
+5. Manual (optional, `PORT=3021 pnpm dev:test`): a `writeDigestVideo`-shaped video shows four
+ panels in the order Digest, Speaker diarization, Speaker names (from the audio), Speaker
+ names (from the transcript); the umtool link (with `UMTOOL_URL` set) is above them inside
+ `VideoPanel`; the two-pane `/channels/<slug>/videos?video=` shows no panels.
+
+## Out of scope
+
+- The two-pane `/channels/[slug]/videos?video=` route stays `VideoPanel`-only
+ (`videos/page.tsx:202-224`).
+- The external operations' cards in `VideoPanel.tsx`; any `Operation` type change; the channel
+ counts' semantics (`hasDigestWithItems` is a move, not a rewrite); slices 5/8/9.
+- `hasDiarization` (live guard). `readVideoDigestAction` (`digestActions.ts:149`) has zero
+ callers — note it in FACTS, do not delete it here.
+- `runOperationChannelJob` gaining `ids`; a per-video digest Run via the backfill job (digest
+ keeps `digestBucketAction`); a per-video Run for a disabled operation.
+- Restyling `StreamActionLog`; any change to `writeDigestVideo` or the ollama stub.
+
+## Handoff — the cadence
+
+On approval, Fable does not implement (memory `plan-then-opus-implements`): write the plan to
+`plans/editor-ia-slice-6.md`, commit it alone, then spawn one `general-purpose` agent,
+`model: "opus"`, with: the plan path, the fish-shell caveats (`git commit -F <file under
+$CLAUDE_JOB_DIR/tmp>`; quote `[slug]`/`[id]` paths and globs; POSIX loops only inside
+`sh -c`), never boot against `transcripts/`, e2e detached, tmp under `$CLAUDE_JOB_DIR/tmp`,
+the two trailer lines, and the report contract (shas + one line each; exact gate outputs;
+e2e per spec with retries; every divergence and why; anything undone). Fable reviews on
+return (`git log --oneline ce2ebd9..`; `videoOperations.ts` + test, `digest.ts`'s
+`digestSectionStates` and the `operations.ts:1176` hunk, `channels.ts`, `operationJobs.ts`,
+`backfillBatch.ts:869-`, `jobReplayRegistry.ts`, `OperationPanel.tsx`, `DigestBody.tsx`
+diff-against-rename, `SpeakerBodies.tsx`, `operationState.ts` + test,
+`videoOperationPanels.ts`, `page.tsx`, `operationActions.ts`, the new specs; re-runs grep
+gates + `pnpm -C common test` + editor units, not e2e), sends fixes via SendMessage, and
+reports.