commit 68a4413c6d5061d54cb824ad03b69ee13bd2b486
parent 626a0d055f5c6120fa278525b8cb6940cec15ff8
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Fri, 28 Aug 2026 02:45:48 -0400
plans: slice 4 planned
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Diffstat:
1 file changed, 470 insertions(+), 0 deletions(-)
diff --git a/plans/editor-ia-slice-4.md b/plans/editor-ia-slice-4.md
@@ -0,0 +1,470 @@
+# Editor IA slice 4 — `/actionable` dissolves
+
+## Context
+
+**Verified read-only against `184cb9f` (clean) on 2026-08-28.** `/actionable` is the fourth
+answer to "what needs doing" (`plans/editor-operations-ia.md:21`): one 696-line page
+(`editor/app/actionable/page.tsx`) rendering ten `SectionConfig`s from a literal array
+(`:92-321`) plus two hand-written sections (`DuplicatesSection :421-475`, `MediaScanSection
+:356-419`), behind a hand-exhaustive `nothingPending` gate (`:77-90`). Slice 4 was unblocked
+by unified-ops step 1 (`STATE.md:204-206`). The IA doc's slice-4 bullet (`:137-152`) and
+reconciliations (`:93-97`) say where things go: per-operation sections onto
+`/operations/<id>`, digest review + uncertain attribution as that page's *attention*
+section, duplicates (and later viewer feedback / context promotion) to a small `/review`
+under Corpus, the rest to `/cleanup`, Sites and the dashboard; `loadActionable.ts` survives
+for the widget; the `SectionConfig` extension point moves with the sections.
+
+**Decided (operator, 2026-08-28):** (a) the two runner pages get a per-channel work table —
+`RunnerOperationView` has none today; (b) stale/missing reports, the per-row *refresh
+report* and *update all reports* go to `/channels`; (c) `/review` holds duplicate clusters
+**and** media-integrity findings — both are human judgements with reviewed state, neither is
+an operation or bytes, so `/cleanup` stays about reclaimable disk.
+
+**Five findings that shape the design (the first two correct the IA bullet):**
+
+1. **The dashboard's digest number does not move — it already IS the band's `reachable`.**
+ `actionableNoDigestCount` (`loadActionable.ts:142-144`) returns
+ `digestWorkOf(row.snapshot).reachable`, the same `reachableOperationWork` that
+ `buildBands.ts:63` folds. So "drop `noDigest` for band `reachable`" is a **rename** with
+ honest labels (`noDigest` → `digestReachable`; "No digest" → "Digest to do"), not a
+ re-sourcing. The changelog says the name changes and the number does not.
+2. **`MonitorWidget` never reads `noDigest`** (`ActionableStrip` reads slug / undownloaded /
+ untranscribed; `LaneDeck.tsx:81-82` likewise). Readers: `page.tsx:80,92`,
+ `NeedsWorkPanel.tsx:74-79`, `dashboard/ChannelsTable.tsx:69`, `dashboard/types.ts:21`,
+ `api/widget/actionable/route.ts:24,42`. The wire field can be renamed.
+3. **The review half of the summary has no consumer outside the page** —
+ `summary.duplicates / duplicateOverrides / mediaScan / mediaScanOverrides`
+ (`loadActionable.ts:52-64`, loaded `:194-201`) are read only by `actionable/page.tsx:341-348`.
+ The dashboard and the widget poll pay the 6.7 MB duplicates parse for nothing. The loader
+ splits: channel-work half keeps its names; review half becomes `loadReviewSummary`.
+4. **`SweepPlan`'s `sweep <slug>` checkbox exists only while choosing a scope**
+ (`SweepPlan.tsx:171-174`), and its total is a **cross-kind sum by design** (`bandForScope`,
+ `:92-96`). The repointed `backfill`/`attribution` assertions target the lane section's row
+ text and `LaneFigures` ("N reachable now" / "N blocked upstream", `SweepLane.tsx:330-335`).
+ `attribution.spec.ts:362`'s "3" (1 diarized + 2 text) is dropped: per-operation pages split
+ it by construction; the sum still lives on the channel page's speakers stage (`:341-343`).
+5. **`/cleanup` is the pool view that lists a snapshot-less channel by label**
+ (`ChannelCleanupCard` = `<section aria-label="cleanup <slug>">`, `:49`; the page reads no
+ `searchParams`), so `site-scope.spec.ts:95-104` re-homes there. `SweepPlan` puts unknown
+ channels into a count, not rows.
+
+**Contradiction to fix in passing:** the IA doc's nav end state (`:66-73`, "eleven", Corpus =
+Dashboard, Channels) omits `/review` while `:93-97` promises it; `nav.ts:44` says eleven. After
+this slice it is twelve. `PLAN.md` 11a is already revised (`:519-521`) — no edit.
+
+**Hazards carried through every commit (slice 3's lesson):** a labelled `<section>` landing
+inside `section[data-lane]` puts its prose and labels into every scoped selector; `getByText`
+/ `getByLabel` match substrings; the runner section's contract
+(`RunnerOperationView.tsx:21-35`) forbids any nested `<section>` and reserves `role="status"`
+(`auto-subs-replace.spec.ts:415` takes `getByRole("status").first()` on
+`/operations/transcription`). Everything new here renders **outside** `section[data-lane]` and
+the runner `<section>`, and carries no `role="status"`.
+
+## Step 0 — the plan on disk
+
+Write this file verbatim to `plans/editor-ia-slice-4.md` and commit it alone:
+`plans: slice 4 planned`.
+
+## Order: six commits after the plan
+
+1. **The seams** — `SectionConfig` + `ChannelWorkTable` extracted; loader, actions,
+ `InlineActionButton`, `FixAllIncompleteButton` moved out of the route; review loader split.
+ `/actionable` renders identically from the shared module. Zero behaviour change.
+2. **Operation pages + dashboard** — `channelWork` slot on `OperationDetail`; sections on
+ `/operations/download|transcription|digest`; `noDigest` → `digestReachable`; dashboard
+ links → `/operations`; revalidation targets. Specs repointed.
+3. **`/channels` reports + `/cleanup` sections.** Specs repointed.
+4. **`/review`** — page, Corpus nav entry, four review components + four actions moved under
+ it. `duplicate-shorts.spec.ts` repointed; new `review.spec.ts`.
+5. **Retire** — delete `editor/app/actionable/`, the redirect, the nav entry, every
+ `revalidatePath("/actionable")`, the prose sweep.
+6. **Docs** — CHANGELOG, IA doc, STATE, FACTS, memory.
+
+Gates after each of 1–5: `pnpm -C <pkg> exec tsc --noEmit` for `common editor export homepage
+umtool mcp`; `pnpm -C common test` (861); `pnpm -C editor exec tsx --test "app/**/*.test.ts"`
+(85 → 86+ with `sections.test.ts`). e2e once after commit 6, detached (memory
+`e2e-run-detached`), on the spec list in Verification. Edit nothing while it runs.
+
+---
+
+## Commit 1 — `editor: the channel-work table and its census leave the /actionable route` (zero behaviour change)
+
+### `SectionConfig` and the eight configs that survive → `editor/app/components/channelWork/sections.tsx`
+
+Move the type from `page.tsx:50-66` and give it an owner and a role:
+
+```ts
+export type SectionConfig = {
+ id: string;
+ // The page that renders this section: an operation id for sections that are one
+ // operation's work, or null for the two cleanup sections (Storage, /cleanup's).
+ // The extension point moved with the sections; it did not disappear.
+ operation: "download" | "transcription" | "digest" | null;
+ // "work" is what the runner or sweep will do; "attention" is what a human must
+ // look at first — a truncated download is not one the runner can retry.
+ role: "work" | "attention";
+ // Which summary list this section draws — declared here so no page hand-pairs a
+ // config with a list (the old page.tsx:92-321 pairing was the same hand-
+ // exhaustiveness as its nothingPending gate).
+ getRows: (summary: ActionableSummary) => ActionableRow[];
+ title; description; countLabel; emptyLabel; getCount; extraColumn?; primaryAction;
+ headerAction?: React.ReactNode;
+};
+export function channelWorkSections(): SectionConfig[] // a function: headerAction is an element
+export function sectionsFor(operation: SectionConfig["operation"]): SectionConfig[] // work first, then attention, declaration order
+```
+
+The eight, moved verbatim from `page.tsx`: `undownloaded` (`:93-109`, download/work),
+`missing-never-fetched` (`:110-130`, download/attention), `untranscribed` (`:131-151`,
+transcription/work), `incomplete-transcripts` (`:152-181`, transcription/attention,
+`headerAction: <FixAllIncompleteButton />`), `digest-warnings` (`:182-202`, digest/attention),
+`short-audio` (`:235-260`, download/attention), `clean-transcribed-audio` (`:261-281`, null),
+`clean-extra-formats` (`:282-303`, null). **`speakers` (`:203-234`) and `stale-reports`
+(`:304-320`) stay page-local** — they die with the page in commit 5.
+
+Unit test `sections.test.ts` (editor units): ids unique; every `getRows` returns a distinct
+field of an `ActionableSummary` literal; `sectionsFor("download")` = `[undownloaded,
+missing-never-fetched, short-audio]`; `("transcription")` = `[untranscribed,
+incomplete-transcripts]`; `("digest")` = `[digest-warnings]`; `(null)` = the two cleanup ids.
+
+### `ChannelWorkTable` → `editor/app/components/channelWork/ChannelWorkTable.tsx` (server component — `primaryAction` is a function and cannot cross to a client component)
+
+```tsx
+export function ChannelWorkTable({ sections, summary }: { sections: SectionConfig[]; summary: ActionableSummary })
+```
+
+`Section` (`page.tsx:553-617`) and `Row` (`:619-696`) ported verbatim with the aria contract
+intact — `<section aria-label={config.id}>`, `<h2>{title}</h2>`, `<p aria-label="<id> empty">`,
+`<tr aria-label="<id> row <slug>">`, columns Slug / countLabel / [extra] / Last report / Last
+sync / Actions, `refreshReport` `InlineActionButton` on every row. Two safe simplifications
+because `stale-reports` is not in the module: the `sectionId === "stale-reports"` cell branch
+(`:653-659`) and guard (`:687-691`) go; `Row`'s own `isStale` (`:635-638`) becomes the loader's
+`isStaleOrMissing(row)` (`loadActionable.ts:67-72`, of which it was a copy). Header comment:
+the aria contract is load-bearing for `channel-work.spec`, `cleanup-actionable.spec`,
+`incomplete-transcript.spec`, `download-format-guard.spec`; **never add `role="status"` here**
+(renders on RUNNER pages).
+
+### Moves (`git mv`, imports repointed, no other edits)
+
+| From | To | Importers to repoint |
+|---|---|---|
+| `actionable/lib/loadActionable.ts` | `editor/app/lib/actionable/loadActionable.ts` | `api/widget/actionable/route.ts:3-8`, `page.tsx:10-15,23`, `actionable/page.tsx:10-25`, `actions.ts:29`; its own `../../lib/requestCache` → `../requestCache` |
+| `actionable/actions.ts` | `editor/app/lib/actionable/actions.ts` | `CommandPalette.tsx:27-30`, the components below; its own `../channels/[slug]/incompleteTranscriptActions` → `../../channels/[slug]/…`, `./lib/loadActionable` → `./loadActionable` |
+| `actionable/components/InlineActionButton.tsx` | `editor/app/components/actions/InlineActionButton.tsx` | `components/dashboard/ChannelsTable.tsx:7`, `components/dashboard/NeedsWorkPanel.tsx:5`, `channels/[slug]/components/NoReportYet.tsx:2`, `channels/[slug]/components/flow/NextAction.tsx:2`, `widget/components/MonitorWidget.tsx:18`, `actionable/page.tsx:26`. Its own `../../channels/…` imports are the same depth — unchanged |
+| `actionable/components/FixAllIncompleteButton.tsx` | `editor/app/components/channelWork/FixAllIncompleteButton.tsx` | its `../actions` → `../../lib/actionable/actions` |
+
+The other five components (`RefreshAllReportsButton`, `RunDuplicateDetectionButton`,
+`DuplicateClusterReview`, `RunMediaScanButton`, `MediaScanFindingRow`) stay in
+`actionable/components/` for now, repointing only `../actions` → `../../lib/actionable/actions`;
+each moves in the commit that gives it a home (3 and 4). `DiagnosticsStage.tsx:31` untouched
+until commit 4.
+
+### The review loader split → `editor/app/lib/review/loadReview.ts`
+
+`ActionableSummary` loses `duplicates`, `duplicateOverrides`, `mediaScan`, `mediaScanOverrides`
+(`:52-64`); `loadActionableSummary` loses the four reads (`:194-201`, `:283-286`). New:
+
+```ts
+export type ReviewSummary = { duplicates: DuplicateReport | null; duplicateOverrides: DuplicateOverrides; mediaScan: MediaScanReport | null; mediaScanOverrides: MediaScanOverrides };
+export async function loadReviewSummary(paths: Paths): Promise<ReviewSummary> // same four reads, same Promise.all, getDuplicateReport via ../requestCache
+```
+
+Carry the field comments (`:53-57`, `:59-62`) verbatim. `actionable/page.tsx` calls both
+loaders — same render. `requestCache.ts:35-37` comment corrected: only `/review` reads the
+duplicates report now.
+
+### `actionable/page.tsx` after commit 1
+
+Imports the eight from `sections.tsx`, renders `<ChannelWorkTable sections={[
+...channelWorkSections(), speakersConfig, staleReportsConfig]} summary={summary} />` behind
+the unchanged `nothingPending` gate. Aria output byte-identical; tsc proves the wiring.
+
+---
+
+## Commit 2 — `operations: each operation page lists the channels with its work, and the dashboard says "digest to do"`
+
+### The slot
+
+`OperationDetail.tsx` gains `channelWork?: ReactNode` beside `operationSettings`/`laneSettings`
+(`:69-70`) and renders it **after** the three-way branch (`:82-107`), inside the outer `div`
+(`:75`), outside every `section[data-lane]` and the runner `<section>`. Comment: outside
+deliberately — inside the runner section it would be a nested `<section>`
+(`RunnerOperationView.tsx:26-28`); inside the sweep section its headings and slugs would land
+in every `section[data-lane]`-scoped `getByText` (`backfill.spec.ts:477-484`). Below the
+console: the console is the operation; the table is what waits for it.
+
+`/operations/[id]/page.tsx` (`:190-217`):
+
+```ts
+const sections = sectionsFor(op.id as SectionConfig["operation"]); // [] for diarization, attribution-*, transcode — the cast is safe only because unknown ids return []
+const channelWork = sections.length > 0
+ ? <ChannelWorkTable key="channel-work" sections={sections} summary={await getActionableSummary(paths)} />
+ : null;
+```
+
+`getActionableSummary` reuses the briefs `buildAutoQueueStatusPayload()` already read via
+`getChannelBriefs` (`requestCache.ts:31-33`) — no extra disk read.
+
+Collision check (done against `auto-queue.spec.ts` and `auto-subs-replace.spec.ts`):
+`locator("section", { has: heading "Auto-transcribe" })` matches by descendant and the new
+sections are siblings; `getByRole("heading", { name: "Transcription", exact: true })` (`:375`)
+and `{ name: "Other" }` count-0 (`:545,1269`) match none of the six titles;
+`section[data-lane]` counts (`:1108,1163`) unaffected; `role="status"` stays reserved. On
+`/operations/digest` the warnings section is outside `section[data-lane="digest"]`, so
+`operation-settings.spec.ts` scopes see nothing new. Two `refresh report <slug>` buttons can
+appear on `/operations/download` (a channel in both `undownloaded` and `short-audio`) — same as
+the old page; `actionable.spec.ts:165-167` already takes `.first()`.
+
+### The dashboard rename
+
+- `loadActionable.ts:142` `actionableNoDigestCount` → `actionableDigestReachableCount`; comment:
+ "the digest band's `reachable`, per channel — `buildBands.ts:63` folds the same call".
+- `api/widget/actionable/route.ts:24,42` `noDigest` → `digestReachable`; the `:16-23` comment
+ keeps its argument (still not a filter criterion), drops "historically the noDigest bucket".
+- `page.tsx:80,92`; `dashboard/types.ts:21` (comment: "the digest band's reachable — what the
+ lane could act on today; not blocked, not deferred"); `NeedsWorkPanel.tsx:74-79` (badge
+ reads `c.digestReachable`, title "N video(s) the digest lane can work on now");
+ `dashboard/ChannelsTable.tsx:36-40` header "Digest to do", title "Videos the digest lane can
+ work on now — the same reachable figure the /operations rail draws; blocked and deferred are
+ not in it", cell `:69`.
+- `NeedsWorkPanel.tsx:31,101` hrefs → `/operations`; `:11` comment "as /actionable" → "as the
+ operation pages".
+
+### Revalidation
+
+`lib/actionable/actions.ts` `clearAllIncompleteTranscriptsAction` (`:232`) and
+`redownloadAllIncompleteTranscriptsAction` (`:252`) → `revalidatePath("/operations/[id]",
+"page")` (the shape `operations/actions.ts:30-33` documents). `refreshAllChannelSnapshotsAction`
+(`:105-106`) → `/channels`, `/`, `/operations/[id]` page, `/cleanup` (comment `:87-90,100-103`
+"/actionable" → "the pages that read the snapshots"). `pipelineActions.ts:213` and
+`snapshotScheduler.ts:203` → `revalidatePath("/operations/[id]", "page")` +
+`revalidatePath("/cleanup")` (keep the lazy-import shape at `snapshotScheduler.ts:196-204`).
+**Leave the old `"/actionable"` lines in place until commit 5** so the page is still
+revalidated while it exists.
+
+### Specs
+
+- `actionable.spec.ts` → `git mv` to `channel-work.spec.ts` (header: "the channel-work tables
+ on the operation pages, and the report controls on /channels"). This commit: T3 (`:39-61`)
+ `goto("/operations/download")`; T4 (`:63-77`) `/operations/transcription`; T8 (`:189-207`)
+ `/operations/download`; T9 (`:209-227`) `/operations/transcription`; T10 (`:236-251`)
+ `a[href="/operations"]`; T1 (`:18-29`) rewritten: `resetData("empty")`,
+ `goto("/operations/download")`, `getByLabel("undownloaded empty")` contains "Nothing
+ pending." — the page-wide empty state is gone with the gate. T2, T5, T6, T7 move in commit
+ 3; T11 unchanged.
+- `incomplete-transcript.spec.ts:127,222,257,280` → `/operations/transcription`; titles
+ `:80,215,250` "actionable" → "transcription page"; the poll `:275-283` keeps its shape.
+- `download-format-guard.spec.ts:156-157` → `/operations/download`.
+- `backfill.spec.ts:747-773` → `goto("/operations/diarization")`; `const lane =
+ page.locator('section[data-lane="backfill"]')`; `expect(lane).toContainText(SLUG)` (the plan
+ row, `SweepPlan.tsx:193-200`; the fixture has a snapshot so the row is in `working`);
+ `expect(lane.getByText(/need their media back first/)).toBeVisible()` (`:140-152`; vidB's
+ audio is removed at `:758-760` so `missingInput > 0`). Comment records the dropped
+ "not hidden behind nothing pending" property.
+- `attribution.spec.ts:356-365` → `goto("/operations/attribution-diarized")`, lane contains
+ `CHANNEL`, `lane.getByText("1 reachable now")`, `lane.getByText("1 blocked upstream")`; then
+ `goto("/operations/attribution-text")`, `lane.getByText("2 reachable now")`. The "3" is
+ dropped (finding 4); the comment says where the sum lives (`:341-343`).
+
+---
+
+## Commit 3 — `channels: the report column and its refresh live with the channels; cleanup: the two cleanup tables live with the bytes`
+
+### `/channels`
+
+- `RefreshAllReportsButton.tsx` → `editor/app/channels/components/`;
+ `refreshAllChannelSnapshotsAction` (`lib/actionable/actions.ts:42-108`, with
+ `RefreshAllResult :31-34`) → `editor/app/channels/actions.ts` (already `"use server"`, holds
+ `refreshChannelSnapshotAction :294-304`); `CommandPalette.tsx:27-30` and the button repoint.
+ `refreshChannelSnapshotAction :302` gains `revalidatePath("/channels")` and
+ `revalidatePath("/operations/[id]", "page")` so a row action flips the marker beside it.
+- `channels/page.tsx:158-166` header: `<RefreshAllReportsButton />` between
+ `SyncAllChannelsButton` and "New channel". Aria (`update all reports`, `… result`, `… error`)
+ unchanged; nothing on `/channels` shares the words.
+- `ChannelRow` (`channels/components/ChannelsTable.tsx:24-26`) gains `report: { generatedAt:
+ string | null; state: "current" | "stale" | "missing" }`, built in `channels/page.tsx:134-137`
+ from `briefs` via new `reportStateOf(brief)` in `lib/actionable/loadActionable.ts`
+ (`isStaleOrMissing` becomes `reportStateOf(...) !== "current"`). New column "Report" after
+ "Last sync" (`:261`): `SortableTh` `sortKey: "report"` (`compareDates` on `generatedAt`,
+ `DEFAULT_DIR.report = "asc"` so stale surfaces first); cell `<Td ariaLabel={`report age for
+ ${slug}`} className={state === "current" ? … : "text-warning"}>` showing `stale` / `missing`
+ / the date. `colSpan` (`:196`) → `9 + columns.length`. `channels-sort.spec.ts:22-30` iterates
+ a fixed header list; `channel-groups.spec.ts:69` counts `rowheader`s — neither breaks.
+ Actions cell (`:436-441`) gains `<InlineActionButton variant={{ kind: "refreshReport", slug }} />`.
+- Footer (`:197-202`, "Run *Refresh report* from a channel") → "…from a row here, or *Update
+ all reports* above".
+
+### `/cleanup`
+
+`cleanup/page.tsx`: load `getActionableSummary(paths)` beside `loadCleanupSummary` (same
+briefs, request-cached); render `<ChannelWorkTable sections={sectionsFor(null)}
+summary={summary} />` **after** the `channel cleanup ledger` section (`:123-138`), before the
+footnote (`:141-151`). Below because the page is an argument in order (hero → sieve → release
+ledger → cards); the tables are the "do it" step. Not merged into `ChannelCleanupCard` (out of
+scope; say so in a comment).
+
+Collision check (done): the card's "Clean audio" is a `StreamActionLog` whose button has no
+aria-label (`common/components/StreamActionLog.tsx:178-185`), so `getByRole("button", { name:
+"clean audio test-transcribe" })` matches only the table's button; the log's
+`aria-label="Clean audio test-transcribe error"` (`:211`) and the table's differ only by case
+and neither is asserted — note in the table's header comment.
+
+Links: `cleanup/page.tsx:143` and `HoldSieve.tsx:307` → `/channels` (text "Refresh reports"
+stays — `cleanup-holds.spec.ts:201` asserts it).
+
+### Specs
+
+- `channel-work.spec.ts` T5 (`:79-114`) → `goto("/channels")`, `getByLabel("report age for
+ test-youtube")` contains "stale"; T6 (`:116-146`) `goto("/channels")`; T7 (`:148-187`)
+ `goto("/channels")`, drop `.first()`; T2 (`:31-37`) → "Actionable is gone from the
+ sidebar": `aside` link `/^Actionable$/` `toHaveCount(0)`.
+- `cleanup-actionable.spec.ts:52,83,102,126` → `/cleanup`; header comment gains one line.
+ Name kept.
+- `site-scope.spec.ts:95-104` → `goto("/cleanup?site=alpha")`, `getByRole("region", { name:
+ "cleanup slow-a", exact: true })` and `slow-b` visible; comment "/cleanup is a shared-pool
+ view and lists every channel, snapshot or not". (Slice 5 owns `:63-87` of the same file —
+ touch nothing else there.)
+
+---
+
+## Commit 4 — `review: the judgements a person makes about the archive get a page under Corpus`
+
+- `editor/app/review/page.tsx` (`dynamic = "force-dynamic"`, `metadata.title = "Review"`,
+ `<h1>Review</h1>`): `loadReviewSummary(paths)`, then `DuplicatesSection`,
+ `DuplicateClusterCard`, `Badge`, `MediaScanSection` moved verbatim from
+ `actionable/page.tsx:353-551` (aria `duplicate-shorts`, `duplicate-shorts empty`, `duplicate
+ cluster <id>`, `media scan`, `media scan empty`, `media finding <key>` unchanged). One intro
+ paragraph: "Corpus review — findings a human decides, not work a lane runs."
+ `loadReview.ts` may `git mv` to `review/lib/`.
+- Components → `editor/app/review/components/`: `RunDuplicateDetectionButton`,
+ `DuplicateClusterReview` (comment `:33-37` "/actionable renders a page heavy enough" →
+ "/review"), `RunMediaScanButton` (`DiagnosticsStage.tsx:31` repoints), `MediaScanFindingRow`.
+- Actions → `editor/app/review/actions.ts` (`"use server"`): `runDuplicateDetectionAction`
+ (`:110-141`), `reviewDuplicateClusterAction` (`:143-204`), `runMediaScanAction` (`:257-300`),
+ `reviewMediaFindingAction` (`:302-320`) with their types; `revalidatePath("/actionable")` at
+ `:139,199,298,315` → `"/review"`. `CommandPalette.tsx` imports `runDuplicateDetectionAction`
+ from `../review/actions`. `lib/actionable/actions.ts` keeps the two incomplete actions.
+- `nav.ts:62-69` Corpus gains `{ href: "/review", label: "Review", icon: ClipboardCheck,
+ keywords: "duplicates duplicate shorts media integrity scan corrupt review queue" }`
+ (`ClipboardCheck` is in the installed lucide).
+- Specs: `duplicate-shorts.spec.ts:207,287,342` → `/review`; `:9` comment. New
+ `review.spec.ts`: `resetData("empty")`, `/review` renders the h1, `getByLabel("duplicate-shorts
+ empty")` "Run detection to scan.", `getByLabel("media scan empty")` "Run a scan to check the
+ media on disk." (media scan had no e2e at all), sidebar Corpus link `Review` has
+ `href="/review"`.
+
+---
+
+## Commit 5 — `editor: /actionable retires and redirects to /operations`
+
+- `git rm -r editor/app/actionable/`. With it: the `speakers`/`stale-reports` configs,
+ `laneAction` (`page.tsx:74-76`), `nothingPending`, and from the loader the `backfill` /
+ `staleOrMissing` lists (`:49,51,263-269`), `actionableBackfillCount` /
+ `actionableBackfillMissingInputCount` (`:150-179`; no other consumer — the guard lives in
+ `backfillLaneEntriesOf`, tested at `operations.test.ts:1503`). `reportStateOf` stays.
+- `next.config.ts:57-61`: add `{ source: "/actionable", destination: "/operations", permanent:
+ false }`; extend the comment in the same voice — "/actionable was four answers to 'what needs
+ doing' on one page; its sections live with the operation, `/channels`, `/cleanup` and
+ `/review` now, and the board is where a bookmark lands. Temporary (307) for the reason
+ above." (`permanent: false` is 307; query strings pass through — `node_modules/next/dist/docs/…/redirects.md`.)
+- `nav.ts`: delete `:79-81` and the `TriangleAlert` import (`:17`, only use); `:44` "eleven"
+ → "twelve"; Operations link keywords (`:77`) gain `actionable needs attention todo`.
+- `revalidatePath("/actionable")` residue: `pipelineActions.ts:213`, `snapshotScheduler.ts:203`,
+ any left in `lib/actionable/actions.ts` — grep gate.
+- Prose sweep (comments only): `common/controller/channels.ts:241`, `channelSnapshot.ts:93,269`,
+ `digestSharing.ts:283` ("/review's per-cluster action"), `common/lib/operations.ts:34,60,1531,1575`
+ ("/actionable's backfill rows" → "the /channels speaker columns"; `:1575` name the widget
+ sync payload and the channel stage), `duplicates.ts:332` ("/review"), `LaneDeck.tsx:76,607`
+ ("the widget payload"), `stageStatus.ts:457`, `DigestStage.tsx:5`, `SpeakersStage.tsx:20,123`
+ ("the /channels station and the operation pages"), `requestCache.ts:9,28-29`,
+ `InlineActionButton.tsx:52` ("see sections.tsx"), `MonitorWidget.tsx:827,917`,
+ `incompleteTranscriptActions.ts:35`, `api/widget/actionable/route.ts:30-34`,
+ `operations.test.ts:1509`, `widget.spec.ts:26,251,320`, `navigation.spec.ts:35-36`.
+- Specs: `navigation.spec.ts:43` deleted; new test "a retired route redirects, never 404s":
+ `goto("/actionable")`, `toHaveURL(/\/operations$/)`, heading "Operations" (shape of
+ `auto-queue.spec.ts:1125-1132`).
+
+---
+
+## Commit 6 — `plans: slice 4 shipped, and the docs say where everything went`
+
+- `editor/CHANGELOG.md` `[Unreleased]`, first bullet, house voice (slice 3's bullet at `:11` is
+ the model): the operation pages list the channels with that operation's work and its
+ attention items; `/channels` carries report freshness and the refresh controls; `/cleanup`
+ carries the two cleanup tables; `/review` (Corpus) holds duplicate clusters and
+ media-integrity findings; `/actionable` redirects to `/operations`. **Nothing on disk changes
+ — no snapshot field, no settings key, no widget section id.** The dashboard's "No digest"
+ badge/column is now "Digest to do", the same number under its right name; the widget payload
+ field `noDigest` is `digestReachable`.
+- `plans/editor-operations-ia.md`: `:71` drop "Actionable until slice 4"; `:70` Corpus =
+ Dashboard, Channels, Review; `:68` twelve; bullet 4 → SHIPPED with hashes; a "Slice 4, as
+ shipped" section recording: the rename-not-resourcing finding; the review-loader split and
+ why; the slot outside `data-lane`; the dropped "nothing pending" property; the "3" that stayed
+ on the channel page; `/cleanup` as the pool re-home.
+- `plans/STATE.md`: "Last updated" prepend; "Recommended next" #3 → DONE with the plan link;
+ the `:239-242` note gains "renamed in slice 4".
+- `plans/FACTS.md`: `## Verified 2026-08-28 — editor IA slice 4 seams` (census + "As shipped",
+ the slice-3 template at `:2249`); table row `:820` ("Actionable sections") → the new paths;
+ the "Retired editor routes" row gains `/actionable`.
+- Memory: new `ia-slice-4-shipped.md` (+ `MEMORY.md` line); amend `slice-3-chosen-next` index
+ line (slice 4 done; next candidates: slice 7 one-pause, slice 5, Phase 6).
+
+---
+
+## Verification
+
+1. After each of commits 1–5: tsc in `common editor export homepage umtool mcp`; `pnpm -C
+ common test` (861); editor units (85 → 86+).
+2. Grep gates after commit 5, over `editor/ common/` excluding `node_modules`: `"/actionable"`
+ → only `next.config.ts` (redirect), `navigation.spec.ts` (redirect test), and
+ `api/widget/actionable` path strings; `actionable/` as an import path → only
+ `api/widget/actionable`; `noDigest` in `editor/app` → only `DigestStage`'s `noDigestIds` and
+ `channels/[slug]/page.tsx:427` (channel page, out of scope); `TriangleAlert` → 0;
+ `nothingPending` → 0; `revalidatePath("/actionable")` → 0.
+3. e2e once after commit 6, detached (`cd editor && setsid nohup sh -c 'pnpm e2e -- <specs>;
+ echo exit=$?' > $CLAUDE_JOB_DIR/tmp/e2e.log 2>&1 < /dev/null & disown`; filenames only):
+ `channel-work.spec.ts cleanup-actionable.spec.ts review.spec.ts site-scope.spec.ts
+ navigation.spec.ts backfill.spec.ts attribution.spec.ts incomplete-transcript.spec.ts
+ download-format-guard.spec.ts duplicate-shorts.spec.ts dashboard.spec.ts widget.spec.ts
+ auto-queue.spec.ts auto-subs-replace.spec.ts operation-settings.spec.ts channels-sort.spec.ts
+ channel-groups.spec.ts channels-actions.spec.ts cleanup-page.spec.ts cleanup-holds.spec.ts
+ perf-budget.spec.ts`. Known flakes (`auto-queue.spec.ts:411`; the cold-compile Videos-tab
+ `toHaveURL`) are retried individually and reported as such. If port 3011 is held use
+ `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, on the e2e fixture, `PORT=3021 pnpm dev:test`): `/actionable` lands on
+ `/operations` (307); `/operations/download` shows three sections below the console,
+ `/operations/transcription` two with the incomplete header actions, `/operations/digest` the
+ warnings section below the settings forms, `/operations/diarization` none; `/channels` shows
+ the Report column, a row "Refresh report", and "Update all reports" in the header; `/cleanup`
+ shows the two tables under the cards; `/review` shows both sections and Corpus has Review;
+ the dashboard's "Needs work" badge title says "can work on now". Kill the server, remove
+ `editor/test-transcripts` and `editor/test-settings.json`, `git status` clean.
+
+## Out of scope
+
+- Renaming the `actionable*` identifiers, `ActionableRow`/`ActionableSummary`, or the
+ `/api/widget/actionable` path (the vocabulary-pass rule: an identifier sweep is reviewable
+ only alone); `editor/app/lib/actionable/` keeps the name as "the actionable census".
+- The widget's `SectionId "actionable"`, `config.ts`, `placement.test.ts`, `MonitorWidget`
+ internals beyond two comment lines.
+- Merging the cleanup tables into `ChannelCleanupCard`; the channel page's `noDigestIds` and
+ `DigestStage`.
+- "Uncertain attribution" as an attention section — no bucket or confidence field exists yet
+ (`attributionTurns.ts:119-123` says the text lane has none); net-new, not a move.
+- Any snapshot field, settings key, or file `name`. Slices 5–9.
+
+## Handoff — the cadence
+
+On approval, Fable does not implement (memory `plan-then-opus-implements`): write the plan to
+`plans/editor-ia-slice-4.md`, commit it alone, then spawn one `general-purpose` agent, `model:
+"opus"`, with: the plan path, the fish-shell caveats (commit via `git commit -F <file under
+$CLAUDE_JOB_DIR/tmp>`; quote `[slug]`/`[id]` paths and globs; POSIX `for … do … done`), never
+boot against `transcripts/`, e2e detached, tmp under `$CLAUDE_JOB_DIR/tmp`, the two trailer
+lines, and the report contract: commit shas with one line each; exact tsc/test outputs; e2e
+pass/fail per spec with any retry; every divergence from the plan and why; anything undone.
+Fable reviews on return (`git log --oneline 184cb9f..`, the hunks in `sections.tsx`,
+`ChannelWorkTable.tsx`, `OperationDetail.tsx`, `[id]/page.tsx`, `channels/components/ChannelsTable.tsx`,
+`cleanup/page.tsx`, `review/page.tsx`, `nav.ts`, `next.config.ts`, and the repointed specs;
+re-runs grep gates + `pnpm -C common test` + editor units, not e2e), sends fixes via
+SendMessage to the same agent, and reports.