Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

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:
Aplans/editor-ia-slice-6.md | 575+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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.