commit 83b3c508e4d9c517b8f6c726122ee53168a167d5
parent 415ce1ee09e7772906b77b0b43458440a572e15b
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Fri, 28 Aug 2026 03:14:46 -0400
plans: slice 4 shipped, and the docs say where everything went
CHANGELOG [Unreleased] leads with the operator-facing story: each section of
/actionable now sits with the work that answers it, /review is new under
Corpus, /actionable redirects, nothing on disk changed, and the dashboard's
"No digest" is "Digest to do" — the same number under its right name, with the
widget's wire field renamed to match.
editor-operations-ia.md: the nav end state is twelve and Corpus carries Review
(the doc promised /review at :93-97 and forgot it at :66-73); slice 4's bullet
is SHIPPED with its hashes; and a "Slice 4, as shipped" section records the six
things a later reader would otherwise have to re-derive — that the digest
rename was a rename and the bullet above it was wrong about that, the
review-loader split and the 6.7 MB parse it stops, why the channel-work slot
renders outside both `data-lane` and the runner section, the two e2e properties
dropped rather than repointed and why, and why the site-scope pool case belongs
on /cleanup rather than in a sweep plan.
STATE.md: "Last updated" leads with the slice; "Recommended next" #3 is struck
through with the hashes; the "noDigest names were deliberately kept" note now
says which three were renamed in slice 4 and which one (DigestStage's
noDigestIds — a list of ids, not this count) was not.
FACTS.md gains "Verified 2026-08-28 — editor IA slice 4 seams": the census with
its anchors, and an "As shipped" half. Two table rows are corrected — the
"Actionable sections" row now names components/channelWork and both loaders,
and the retired-routes row carries /actionable and the two API paths that did
not move.
Memory: new ia-slice-4-shipped.md with its MEMORY.md pointer, and
slice-3-chosen-next amended where it still called slice 4 the next thing and
still listed all four noDigest names as deliberately kept.
Nothing under transcripts/ was read or written for this commit.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Diffstat:
4 files changed, 135 insertions(+), 11 deletions(-)
diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md
@@ -1,6 +1,7 @@
# Changelog
## [Unreleased]
+- **"What needs doing" is answered on the page that does it, and `/actionable` is gone.** One page listed ten kinds of pending work, and it was the fourth place in the editor that answered the same question — so a channel with undownloaded videos appeared on the dashboard, on `/channels`, on `/operations` and there, four times, in four vocabularies. Each of its sections now sits with the work: **Download** and **Transcription** each carry the channels with that operation's backlog and its attention items (videos lost before they were ever fetched, truncated downloads, truncated transcripts — the ones a runner cannot simply retry), **Digest** carries the passes that recorded warnings, `/cleanup` carries the two reclaimable-audio tables, and `/channels` carries how fresh each channel's report is, with *Refresh report* on the row and *Update all reports* in the header — the report is the caveat on every count in that row, so it belongs beside them. Duplicate clusters and media-integrity findings get a new **Review** page under *Corpus*: both are flag-only, both a human decides, and neither is an operation or a pile of bytes. `/actionable` redirects to `/operations`, so every bookmark still lands. **Nothing on disk changes** — no report field, no setting, no widget section. The dashboard's *No digest* badge and column are now **Digest to do**: it is the same number under its right name, the figure the digest lane could act on today, which is what it has always been. The monitor widget's own data feed renames its `noDigest` field to `digestReachable` to match; a pinned widget keeps working and shows the same figure.
- **Re-acquired audio is handed to auto-transcribe instead of being deleted under it.** On a channel that only downloads subtitles, the audio the speaker lane fetches sits next to YouTube's own auto-captions — which is precisely the shape the *replace auto-captions* transcription runner looks for, and the channel's report is rebuilt about a second after any work on it finishes. Nothing coordinates the two, so the runner could start on a file the speaker lane was about to delete: the transcription then fails, and the video is written to the channel's permanently-honoured failed list. The lane now checks, at the moment it would delete, whether the transcription policy would take this video — and if it would, leaves the audio for it, saying so per video and in the run's summary line. That audio then behaves like any other download: it stays until you run *Clean audio from transcribed*. Audio is still deleted immediately in every other case, still kept for a video marked *do not clean*, and the hand-off is refused when free disk is below the mark the download runner itself would need — the lane never keeps a file the runner would have refused to fetch.
- **A video whose audio disappears mid-transcription is skipped, not marked failed.** Losing the input file is a media problem that fixes itself on the next attempt; recording it as a failed transcription blacklisted the video for the channel's manual whisper run for good. Both the local engine and a delegated remote one now report it as "no audio", which the queue already treats as "try again later".
- **Speaker names go stale when the transcript they were read from is replaced.** A name is a claim about a particular text; replacing a video's auto-captions with our own transcript changes the wording, the timings and sometimes who is in it, and nothing recorded which text the names came from — so the old names kept reading as current. New speaker records note it and are re-made when it changes. Records already on disk are untouched: they never carried the field, and inventing an answer for them would re-run speaker attribution across the whole archive.
diff --git a/plans/FACTS.md b/plans/FACTS.md
@@ -817,12 +817,12 @@ anything with a settings surface gets one live run and one assertion.
| Job kinds | `common/jobs/jobKinds.ts:21-40, 44-255` | ~30 entries. **No entry sets `defaultTier`** — declared but unused so far. |
| Replay handlers | `editor/app/jobs/jobReplayRegistry.ts:75-236` | Bucket kinds re-derive ids from the live snapshot (`:50-57`), never a frozen list. |
| Snapshot buckets | `common/controller/channelSnapshot.ts:57-143` | 21 buckets, all `string[]` of video ids. Readers default `?.length ?? 0`. `undownloadedIds` is deliberately **outside** `buckets` (`:144`). |
-| Actionable sections | `editor/app/actionable/page.tsx:30-46` | `SectionConfig` is module-private. Counters live in `actionable/lib/loadActionable.ts`. |
+| Channel-work sections | `editor/app/components/channelWork/sections.tsx` | `SectionConfig` + `channelWorkSections()` + `sectionsFor(op)`; rendered by `ChannelWorkTable.tsx` (a SERVER component — `primaryAction` is a function). Eight sections: three on `/operations/download`, two on `/operations/transcription`, one on `/operations/digest`, two (`operation: null`) on `/cleanup`. Counters live in `editor/app/lib/actionable/loadActionable.ts`; the review half is `editor/app/review/lib/loadReview.ts`. Unit-tested in `sections.test.ts`. |
| Widget sync payload | `editor/app/api/widget/sync/route.ts:15-26` | Comment at `:10-14` states it deliberately stays "a handful of scalars". `buildWidgetSyncPayload()` (`:30`) is exported for SSR seeding. |
| Pipeline band | `editor/app/components/dashboard/PipelineBand.tsx:63-114` | Pure props, no fetching. `<Instrument dotClass=…>` encodes state color. |
| Operations board | `editor/app/operations/page.tsx` | `/operations`. Rail + arbiter + sync row, SSR-seeded, polls `/api/auto-queue/status` every 3 s. `data-board="operations"` carries `data-hydrated`. |
| One operation | `editor/app/operations/[id]/page.tsx` | `/operations/<id>`, **routed off `operationCatalog()`** — an unknown id is `notFound()`, a new registry entry needs no route work. Runner ids (`download`, `transcription`) render `RunnerOperationView`; everything else renders `SweepLane` for its lane inside `<section data-lane="digest"|"backfill">`. |
-| Retired editor routes | `editor/next.config.ts` `redirects()` | `/auto-queue` → `/operations`, **temporary (307)**, not permanent — a 308 on a self-hosted admin surface is a support call with no remedy. The API paths `/api/auto-queue/{status,control}` did **not** move. |
+| Retired editor routes | `editor/next.config.ts` `redirects()` | `/auto-queue` → `/operations` and `/actionable` → `/operations`, both **temporary (307)**, not permanent — a 308 on a self-hosted admin surface is a support call with no remedy. Query strings pass through. The API paths `/api/auto-queue/{status,control}` and `/api/widget/actionable` did **not** move. |
---
@@ -2612,3 +2612,60 @@ replacement leaves every digest section looking fresh over rewritten text, exact
attribution did before `f661677`. Same field, same record-carries-it rule, ~10 lines. NOT
done here because it would re-queue a local LLM digest for every replaced transcript, which
is a cost decision for the operator, not a correctness one.
+
+
+## Verified 2026-08-28 — editor IA slice 4 seams
+
+Census taken before writing `editor-ia-slice-4.md`, against `184cb9f`. Read-only observation
+at census time; the "As shipped" notes below were added with the docs commit.
+
+**`/actionable` was 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`) whose own comment said a section missing from it is a
+section that never renders.
+
+**The dashboard's digest number was ALREADY the band's `reachable`.**
+`actionableNoDigestCount` (`loadActionable.ts:142-144`) returned
+`digestWorkOf(row.snapshot).reachable` — the same `reachableOperationWork` that
+`buildBands.ts:63` folds. So the IA doc's "drop `noDigest` for band `reachable`" described a
+change to a number that was already that number. Readers were `page.tsx:80,92`,
+`NeedsWorkPanel.tsx:74-79`, `dashboard/ChannelsTable.tsx:69`, `dashboard/types.ts:21`,
+`api/widget/actionable/route.ts:24,42`. **`MonitorWidget` never read it** (`ActionableStrip`
+reads slug / undownloaded / untranscribed; `LaneDeck.tsx:81-82` likewise), which is what made
+the wire field renameable.
+
+**The review half of the summary had no consumer outside the page.**
+`summary.duplicates / duplicateOverrides / mediaScan / mediaScanOverrides`
+(`loadActionable.ts:52-64`, loaded `:194-201`) were read only by `actionable/page.tsx:341-348`
+— so the dashboard render and every widget poll paid the 6.7 MB duplicates parse for nothing.
+
+**`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`). A channel with no snapshot goes into a COUNT there, not into a row — which is why
+the site-scope pool assertion could not move to a sweep plan.
+
+**`/cleanup` lists a snapshot-less channel by label.** `ChannelCleanupCard` is
+`<section aria-label="cleanup <slug>">` (`:49`) and the page reads no `searchParams` at all.
+
+**As shipped** (`a623958` → `43c4e26`):
+
+- `SectionConfig` gained `operation` (`"download" | "transcription" | "digest" | null`),
+ `role` (`"work" | "attention"`) and `getRows`, so a page never hand-pairs a config with a
+ summary list. `sectionsFor(op)` returns work then attention in declaration order, and `[]`
+ for an unknown id — which is what makes the cast at the `/operations/[id]` call site safe.
+- `ChannelWorkTable` renders **outside** `section[data-lane]` and outside the runner's
+ `<section>`, and carries no `role="status"`. All three constraints are recorded in its
+ header comment with the specs that depend on them.
+- The census lost `backfill`, `staleOrMissing`, `actionableBackfillCount` and
+ `actionableBackfillMissingInputCount`; `reportStateOf(brief)` is new (three states) and
+ `isStaleOrMissing(row)` is now defined in terms of it.
+- `refreshAllChannelSnapshotsAction` lives in `editor/app/channels/actions.ts`; the four
+ review actions in `editor/app/review/actions.ts`; the two global incomplete-transcript
+ actions stay in `editor/app/lib/actionable/actions.ts`.
+- Revalidation of the operation pages is `revalidatePath("/operations/[id]", "page")` — the
+ route-pattern form, the only one that reaches `/operations/digest` without naming it
+ (`node_modules/next/dist/docs/.../revalidatePath.md`; the shape `operations/actions.ts:30-33`
+ already documented).
+- `editor/app/lib/actionable/` keeps its name deliberately: renaming the `actionable*`
+ identifiers is a vocabulary pass, and `/api/widget/actionable` is a wire contract.
diff --git a/plans/STATE.md b/plans/STATE.md
@@ -3,7 +3,16 @@
The working memory for the local-AI derived-corpus work. Rewritten at the end of every
session, before context is cleared. See [`README.md`](README.md) for the protocol.
-**Last updated:** 2026-08-28 — **the re-acquire hand-off shipped** (`e450c2c`, `f661677`,
+**Last updated:** 2026-08-28 — **editor IA slice 4 shipped** (`a623958` → `43c4e26`, plus
+the docs commit): `/actionable` is gone and redirects to `/operations`. Its per-operation
+sections are the channel-work tables on `/operations/download|transcription|digest`, report
+freshness and the two refresh controls are on `/channels`, the two cleanup tables are on
+`/cleanup`, and duplicate clusters plus media-integrity findings are a new `/review` under
+Corpus. The nav is twelve. The dashboard's "No digest" is "Digest to do" — a rename, not a
+re-sourcing: `actionableNoDigestCount` already returned the digest band's `reachable`, so no
+number on any screen moved. The widget wire field is `digestReachable`. Nothing on disk
+changed. See "Slice 4, as shipped" in `editor-operations-ia.md`.
+Previously: 2026-08-28 — **the re-acquire hand-off shipped** (`e450c2c`, `f661677`,
`bec4775`): re-acquired audio on a subtitle channel is no longer
deleted under a transcription that the auto-queue policy would have started on it. It is
handed over when `autoQueue.transcription` would draw the video from
@@ -201,9 +210,10 @@ nothing renders.
run**; the trade is that handed-off audio persists like any other download until a
Clean-audio sweep. Mechanics and the file:line trail: FACTS.md "Verified 2026-08-27 —
the re-acquire / auto-transcribe hand-off".
-3. **Editor IA slice 4** (`/actionable` dissolves) — **UNBLOCKED**: it wanted the per-state
- split (`blocked`, `deferred`, `partial`) and the coverage pair (`eligible`, `present`),
- which is what `snapshot.backfill.digest` carries and the deleted bucket did not.
+3. ~~**Editor IA slice 4** (`/actionable` dissolves)~~ — **DONE 2026-08-28**, `a623958` →
+ `43c4e26`. Plan: [`editor-ia-slice-4.md`](editor-ia-slice-4.md); outcome: "Slice 4, as
+ shipped" in `editor-operations-ia.md` and the FACTS section "Verified 2026-08-28 — editor
+ IA slice 4 seams".
4. **Relocate `omnimirror`'s media** once the platter is mounted (131 GB off a 94%-full SSD).
5. **Phase 6 Ollama `/ask`** — genuinely independent; a good parallel task.
6. Decide `attribution-text`'s fate on that one channel: ~194,000 chunk-level calls is a
@@ -240,7 +250,10 @@ measurements in [`FACTS.md`](FACTS.md) under "Verified 2026-08-26 — unified-op
4. **The `noDigest` view-model names were deliberately kept** — `DashboardChannel.noDigest`,
the `/api/widget/actionable` wire field, `actionableNoDigestCount`, `DigestStage`'s
`noDigestIds`. They mean "videos with no current digest", which is still what they hold;
- renaming a wire field is its own change.
+ renaming a wire field is its own change. **Renamed in slice 4** (2026-08-28): the first
+ three are `digestReachable` / `actionableDigestReachableCount`, because the number is the
+ digest band's `reachable` and the honest name says so. `DigestStage`'s `noDigestIds` is
+ untouched — it is a list of ids, not this count.
5. **The transcode band was NOT taken with this step.** Three docs filed it "with step 1" only
because both were thought to need one regeneration of the snapshots; step 1 needs none, so
the coupling is void. It is its own snapshot-shape change (the writer recording a transcode
diff --git a/plans/editor-operations-ia.md b/plans/editor-operations-ia.md
@@ -65,10 +65,10 @@ and concludes the model was wrong.
## The nav, end state
-Four groups, eleven top-level entries, down from three groups and nineteen:
+Four groups, twelve top-level entries, down from three groups and nineteen:
-- **Corpus** — Dashboard, Channels
-- **Operations** — Operations *(+ Actionable until slice 4, Schedule until slice 8)*
+- **Corpus** — Dashboard, Channels, Review
+- **Operations** — Operations *(+ Schedule until slice 8)*
- **Sites** — Sites *(+ Charts, Search aliases, Deploy, Build, Homepage until slice 5)*
- **Machine** — Jobs, Active, Workers, Cleanup, Saved videos, Settings, Changelog
@@ -131,7 +131,8 @@ dependencies allow. Sizes are S/M/L.
attribution fieldsets moved to `/operations/<id>`, each with its own form and action;
`saveSettingsAction` lost the four blocks and the four hidden `*FormPresent` markers with
them. 562 lines out of `SettingsForm`, 171 out of its action. **M.**
-4. **`/actionable` dissolves.** ~~Depends on unified-ops step 1~~ — **SATISFIED 2026-08-26**
+4. **`/actionable` dissolves — SHIPPED** (`a623958` → `43c4e26`; see "Slice 4, as shipped"
+ below). ~~Depends on unified-ops step 1~~ — **SATISFIED 2026-08-26**
(`efb0cf9` → `00b1c8a`): `snapshot.backfill.digest` is now the only digest work list, and it
carries what this slice needs and the deleted `noDigest` bucket did not — the per-state
split (`blocked`, `deferred`, `partial`) and the coverage pair (`eligible`, `present`).
@@ -316,3 +317,55 @@ the lane form leaves `backfill.enabled` alone. 7/7.
`components/forms/Field.tsx` says so at the top); touch `WorkersField` (slice 8), the pause
trio (slice 7) or `/actionable` (slice 4); change any persisted key or field `name`; or retire
a route — nothing redirects, because nothing moved off a URL.
+
+## Slice 4, as shipped
+
+Seven commits: `a623958` (the plan) → `481698a` (the seams) → `a94d5e3` (operation pages +
+the dashboard rename) → `68f1079` (`/channels` reports, `/cleanup` tables) → `202fed6`
+(`/review`) → `43c4e26` (retire + redirect) → the docs commit this section is in. The plan is
+[`editor-ia-slice-4.md`](editor-ia-slice-4.md).
+
+**The dashboard's digest number is a RENAME, not a re-sourcing, and the slice-4 bullet above
+had it wrong.** `actionableNoDigestCount` already returned `digestWorkOf(row.snapshot).reachable`
+— the same `reachableOperationWork` that `buildBands.ts:63` folds into the digest band. So
+"drop `noDigest` for band `reachable`" was describing a change to a number that was already
+that number. What shipped is honest names on the same figure: `actionableDigestReachableCount`,
+wire field `digestReachable`, column "Digest to do", badge title "N video(s) the digest lane
+can work on now". The changelog says the name changed and the number did not.
+
+**The census was carrying a 6.7 MB parse for one reader.** `summary.duplicates`,
+`duplicateOverrides`, `mediaScan` and `mediaScanOverrides` had exactly one consumer — the
+review half of `/actionable` — and every dashboard render and every widget `/api/widget/actionable`
+poll loaded them. They are `ReviewSummary` / `loadReviewSummary` now, in `review/lib/`, read
+only by `/review`.
+
+**The channel-work slot renders outside `section[data-lane]` and outside the runner's
+`<section>`, and both halves of that are load-bearing.** Inside the runner section it would be
+a nested `<section>`, which `RunnerOperationView`'s contract forbids; inside the sweep section
+its headings, prose and channel slugs would land inside every `data-lane`-scoped `getByText`
+in the suite — the same failure slice 3 paid for with the lane form's "The plan" paragraph.
+It also carries no `role="status"`: the runner pages reserve that role, and
+`auto-subs-replace.spec.ts:415` takes `getByRole("status").first()` on
+`/operations/transcription`.
+
+**Two properties were dropped rather than repointed, both because the page they described is
+gone.** `backfill.spec.ts` (10) asserted that the backfill section was "not hidden behind
+nothing pending" — that gate was a hand-exhaustive boolean over ten lists, and an operation
+page has no such gate for a section to hide behind. And `attribution.spec.ts`'s "3" was
+1 diarized + 2 text summed in one `/actionable` row; the per-operation pages split it by
+construction, so each page asserts its own band instead. The sum still lives on the channel
+page's speakers stage, asserted twenty lines above in the same test.
+
+**`/cleanup` is where the site-scope pool case belongs.** `site-scope.spec.ts` used
+`/actionable`'s stale-reports rows to prove a pool view ignores the active site.
+`ChannelCleanupCard` is `<section aria-label="cleanup <slug>">` and `/cleanup` reads no
+`searchParams` at all, so it makes the same point more directly. `SweepPlan` was the wrong
+target: it puts a snapshot-less channel into a count, not into a row.
+
+**What slice 4 deliberately did not do:** rename the `actionable*` identifiers,
+`ActionableRow`/`ActionableSummary`, or the `/api/widget/actionable` path — that is a
+vocabulary pass, reviewable only on its own, and the wire path is a contract a pinned widget
+is polling. `editor/app/lib/actionable/` keeps the name as "the actionable census". The two
+cleanup tables were not merged into `ChannelCleanupCard`. "Uncertain attribution" is not an
+attention section: no bucket or confidence field exists yet, so it would be net-new rather
+than a move.