Archilyzer · Source

archilyzer

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

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