# One core for Archilyzer — a holistic simplification plan ## Context The workspace has grown since 2026-04 into seven packages (~200k source lines, 659 commits, accelerating 40 → 233 commits/month) by building the boat while sailing it: each scheduler, sweep, runner, derived feature, publish target and tool landed with its own module, page, settings block, entry point and doc. Two design docs already diagnose halves of this and are mostly shipped — `plans/unified-operations-model.md` ("four schedulers for one kind of work"; steps 1–4 done, 5 half, 6 open) and `plans/editor-operations-ia.md` ("the backend found its noun, the UI had not"; 8 of 9 slices shipped). This plan is the whole-app version of the same question, and the answer is **yes**: there is one core underneath all of it, it is already half-built, and the simplification is mostly *deleting the layers that predate it* rather than writing a new one. Decisions taken with the operator (2026-09-07): - **Scope: sites AND projects.** umtool's project model and `scripts/report-to-video` become project kinds in the core, consuming archives only through the published contract. **Everything report-to-video lives under the umtool umbrella** — the scripts move to `umtool/report-to-video/` (workspace package `umtool-report-to-video`) in Phase 0, and umtool is the home of every project *kind*; the core holds only the project model. umtool reads as **"universal media tool"** (the operator's backronym) — the one place media-shaped work on the archive lives, whatever the project kind. Use that expansion in `umtool/docs/README.md` and the root README's doc index. - **Consolidate in place, step by step.** Every step deletes something and lands green. On-disk formats, `settings.json` keys, live URLs and the `corpus.json` v3 contract are frozen. Retired routes redirect, never 404 (the umtool rule both consoles already cite). - **Daemon split: yes, as the last phase.** - **All four pains are in scope**; the phases below are ordered by dependency, not taste. ## What the survey found Five read-only surveys (common, editor, publish, tooling, docs/ops) on 2026-09-05. The short version, file-anchored; the numbers only matter where they change what to do. **The core is real and already the organizing idea.** `common/lib/operations.ts` is a registry of `OperationDescriptor`s (state per video, lane, cost, scope, trigger); `common/jobs/autoQueuePolicy.ts` is a genuine HTB-style rule tree; `common/lib/corpus.ts` defines the public contract once; `common/lib/site.ts` makes Site a first-class type; `common/lib/paths.ts` is the single path/env override surface; `common/components/` (17k lines) is the shared UI all three Next apps draw. That is the core. Everything else is what grew around it before it existed. **What grew around it** (each is a self-described clone or a documented pre-core layer): 1. **Dispatch, still four ways.** `controller/digestSweep.ts` (368), `backfillSweep.ts` (517, header: "cloned from digestSweep.ts"), `autoRunner.ts` (1194), and the sync heartbeat, beside `controller/arbiter.ts` (416) which is built, not persisted, and refuses to run next to an armed sweep. `backfillBatch.ts` (937) says in its header it is structurally `digestBatch.ts`. `arbiterWork.ts` says it is "SEPARATE from autoRunner's buildChannelWork, and not a refactor of it". Four sweep planners (`lib/sweepPlan.ts`, `controller/sweepPreview.ts`, `sweepRecency.ts`, `buildDigestSweepPlan`). 2. **Two work-classification systems in one snapshot.** `channelSnapshot.ts:70-180`: `buckets` (~20 closed string-list fields, the pre-registry shape) beside `backfill: Record` (the registry shape, and "no longer the backfill lane" — readers must use `backfillLaneEntriesOf()` or count 75,000 videos wrong). 3. **One contract writer, five hand-rolled readers.** Writer: `controller/buildIndex.ts` + `bin/compose-site.ts`. Readers that each re-walk `corpus.json → manifest → slugToPage → page-NNNN.json`: `mcp/src/source.ts` (1388, three transports, the best one), `common/components/{transcript,subs,posts,digest}Cache.ts` (browser), `export/app/lib/offlineCache.ts`, `scripts/report-to-video/cues.mjs` (270, zero shared code), and umtool via report-to-video. `pageFileName` exists three times in `lib/manifest.ts` (lines 40, 46, 98); version constants live in three files (`corpus.ts`, `siteDescriptor.ts`, `manifest.ts`); `shipsPwa()` and the `_headers` CORS block are duplicated with "keep in sync" comments. `mcp/src/search.ts` (1563) is a server-side re-implementation of the ranking the viewer runs in `common/components/searchPipeline.ts` (852). `mcp/package.json` does not even declare its dependency on common (it resolves through the workspace by accident). 4. **The editor holds core logic and draws each noun several ways.** Read-model builders live in the app, not the core: `jobs/active/buildActiveJobs.ts` (517), `api/widget/sync/route.ts` (325 — the dashboard imports a builder *from a route file*), `operations/lanes.ts` (368), `channels/[slug]/lib/channelFlow.ts` (582), `stageStatus.ts` (586), `workers/buildWorkers.ts`. Three channel tables, a running job drawn five ways (`JobsTable`, `RunningJobsList`, `LaneStrip`, `InFlightList`, `MonitorWidget`), two vocabularies for the channel pipeline (`stages/*` cards vs `flow/*` stations), the widget re-rendering the dashboard's payloads through a second 1317-line renderer. 239 server actions in 40 modules; settings written from ≥6 files. Seven 11-line API routes exist only to poll what a page already renders. 5. **The publish verb has seven entry points** (root `pnpm build`, export's `build`/`build:nodata`/`build:hub` — the first two differ only by name —, four editor actions, `docker/publish-site.sh`, `docker/build-site.sh`), all funnelling into `editor/app/sites/lib/buildDeployCore.ts`, which lives in the *editor*, so the CLI and docker paths cannot share it. `build:hub` has no operator entry point. 6. **Projects are a second system.** umtool (25k + 10k of `song/` scripts) has its own project model, data root (`~/reports`), LMDB index, nav rule, FACTS file (`quirks.md`), and hardcoded archive origin (`lib/archive.ts`); `umtool/lib/decisions.ts:23` calls report-to-video "the second video pipeline in this repo". `corpus` means three things (`common/lib/corpus.ts`, mcp's source handle, `umtool/lib/corpus.ts` = pitch histogram). 7. **Config and docs.** `settings.json` has ~90 leaf keys validated by 1,694 lines of hand-written sanitizers (`lib/settings.ts`, the hottest code cluster in git: 146 touches with its form and action); `settings.json.example` omits 26 of 28 live keys and carries three dead ones; 68 app env vars plus 16 disjoint docker ones; `PARALLEL_TRANSCRIBE_LIMIT` is documented and read nowhere. Ten top-level docs overlap pairwise (worktrees ×3, container ×2, install ×2); `DEPLOY_DOCKER.md` is about building sites, not running them. `common/bin/` is 58% one-off bake-off harnesses (3,828 of 6,545 lines). 8. **Layering exists only by convention.** `common/package.json` has no `exports`, so all ~400 files are public API; `lib → controller` (8 imports), `lib → jobs` (6), `jobs → controller` (2) and `controller ↔ ytdlp` are real back-edges. The one architecture guard that exists (`controller/noCorpusWalkInRenderPaths.test.ts`) is a grep, and it works — so the pattern is proven. **What is healthy and stays**: the on-disk corpus (`transcripts/channels//data//` + sidecars), the LMDB index, the operation registry, the policy tree, the job registry with distinct queue keys as the only concurrency mechanism, `paths.ts`, the shared component library, the Playwright suite as the UI's test, the e2e queue lock, and the seven "what must not be lost" invariants in `unified-operations-model.md` plus the naming hazards in `plans/FACTS.md` ("Naming hazards"; `SUB_FILE_RE`; `summaries`; `band.ts` directive-free). ## The target: one core, two products, one hinge ``` ┌──────────────── core (common/) ────────────────┐ products │ model → corpus → operations → dispatch → publish │ views → ui └──────────────────────────────────────────────────┘ Archive: corpus on disk ──operations──▶ derived sidecars ──publish──▶ site (contract) │ ArchiveReader ◀──┘ (fs | http | hub) Project: archives (via ArchiveReader only) ──project kind──▶ report → video / song (kinds live in umtool/: umtool/report-to-video, umtool/song) shells: editor (archive console) · viewer (export) · homepage · umtool (project console) · mcp (archive over MCP) · archilyzer CLI · daemon (phase 6) ``` - **Two product nouns.** An **Archive** is a corpus turned into derived artifacts and published as one or more Sites. A **Project** is a derived work (cited report, video, song) built *from* archives. Projects and the MCP and the viewer read an archive the same way: through the published contract. That hinge — one `ArchiveReader` — is what makes the "sites and projects" core one thing rather than two repos sharing a folder. - **Six core layers, dependency strictly downward, enforced by a test.** `model` (types, on-disk formats, paths, sidecar declarations, settings schema; browser-safe) → `corpus` (read side of a local corpus; enumerators typed by cost tier; snapshot) → `operations` (the registry; `state()` and `run(unit)` per entry; engines stay in `scripts/` behind `paths.ts`) → `dispatch` (job registry + policy tree + persisted arbiter; *the* scheduler) → `publish` (index, compose, contract, ArchiveReader) → `views` (pure read-model builders every UI draws) → `ui` (shared React). `projects` sits beside `operations` and depends on `publish` only. - **Every app is a shell.** It owns routing, forms and layout; it imports views and calls core verbs. A core verb (`buildSite`, `runOperation`, `composeHub`) has exactly one implementation, and the CLI, the editor action, the docker script and the e2e fixture all call it. - **One CLI, `archilyzer`.** Subcommands over the same verbs replace 22 bins, five report-to-video bins, umtool's bin, four docker scripts and the pnpm-script twins. - **Not a directory move.** `common/` keeps its name and its directories; the layers are enforced by a dependency-direction test, and new code lands in three new directories (`common/views/`, `common/lib/archive/`, `common/projects/`). Renaming the package to `archilyzer-core` is a one-line sed left for the end, if wanted. ## The phases Ordered by dependency: 1 unblocks 3, 2 unblocks 5, 3 and 4 make 6 small. Each phase is several slices in the repo's existing cadence (a slice = one reviewable diff that deletes something, with a `plans/` note when it ships). Sizes are slices, not days. ### Phase 0 — Guardrails and dead weight (1 slice) Makes the later deletions safe and cheap. - **Dependency-direction test** `common/architecture.test.ts`, modelled on `controller/noCorpusWalkInRenderPaths.test.ts`: `lib` may not import `controller` or `jobs`; `jobs` may not import `controller`; `components` may not import `controller`, `jobs` or `ytdlp`. Seed it with an allow-list of today's 16 back-edges so it passes, then burn the list down (the `lib/settings.ts → jobs` re-export of `AutoQueueSettings` moves the type into `lib`; `lib/operations.ts → controller` moves the two functions it needs). - **`common/package.json` `exports`** as subpath patterns (`"./lib/*"`, `"./controller/*"`, …) so deep imports keep working but the surface is declared; `mcp/package.json` declares `yt-dlp-transcript-common: workspace:*`. - **Delete**: root `sites/` (dead example), `common/bin/migrate-to-sites.ts` (one-shot, done), `sites/jeralyzer/site.json.example`, the tail of `create-archives.sh`, the six stale `export/.compose-cache/*.json` / `.r2-staging` trees from the working dir. Move `bin/digest-bakeoff.ts`, `attribution-bakeoff.ts`, `attribution-pilot.ts` to `plans/tools/` beside their results in `plans/bakeoff/`. - **`git mv scripts/report-to-video umtool/report-to-video`.** A rename only: the workspace entry in `pnpm-workspace.yaml`, the package name (`umtool-report-to-video`), the root `test:scripts` glob, umtool's two path references (`lib/report/driver.mjs:14` resolves `../scripts/report-to-video`; `bin/umtool.mjs:516` prints the literal command), and the AGENTS.md / README clip sections. Behaviour, bins and exports are unchanged; `pnpm test:scripts` proves it. - **One `pageFileName`** in `lib/manifest.ts`; one `CONTRACT` object in `lib/corpus.ts` holding every version constant (`CORPUS_SPEC_VERSION`, `SITE_DESCRIPTOR_VERSION`, the four manifest versions, `SUMMARIES_PAGE_SIZE`, layer names) — the three files re-export from it, nothing changes on the wire. ### Phase 1 — Dispatch: one scheduler (5 slices) **SHIPPED 2026-09-08** on branch `one-core/phase-1` (`7f294df` → `81a663f` plus a docs commit, 36 commits, unmerged, off Phase 0's `af2a360`), all five slices. The record — every slice's sha range, every divergence, both operator gates and the totals — is [`one-core-phase-1.md`](one-core-phase-1.md); the anchors are [`FACTS.md`](FACTS.md#one-core-phase-1-verified-2026-09-08). **No live number moved**, and both gates (A: the digest lane's production pass, from `one-core/phase-1-gate-a`; B: the backfill lane's re-download scope) are the operator's and still outstanding. Finishes `unified-operations-model.md` steps 5 and 6 and deletes what they retire. This is the phase with live numbers on a 78,000-video corpus; every slice is measured before/after with offline `tsx` over the real corpus (`plans/tools/phase1-numbers.ts`), never a second editor. **Three surveys on 2026-09-07 changed one word of the plan below: the arbiter is not persisted, it is DELETED.** Zero `operations-arbiter` records among 1,305 production jobs, no `.arbiter/` state, no boot resume, and it cannot dispatch download or transcription — the two lanes carrying 100% of live dispatch. The auto-queue runner is the proven loop (persisted picks, boot resume, worker slots, platform backoff, an e2e-pinned console), so the RUNNER generalizes to four lanes and the arbiter goes with the sweeps. The slice-level plan, with the line numbers and the measurements, is [`one-core-phase-1.md`](one-core-phase-1.md). 1. **1.1 — Four lanes in the model.** `AutoQueueKind` widens to `transcription | download | digest | backfill` and is exported as `LANES`; `PauseLane` becomes an alias; the two new lanes get a policy, a tree, a status entry and a state block. No dispatch, no behaviour, no number moves. 2. **1.2 — The runner runs operations.** `digestBatch.ts` + `backfillBatch.ts` → one `controller/operationBatch.ts` parameterised by the descriptor; `autoRunner.run` branches on lane; the GPU idle-only rule keys off `contendsFor`, not off the presence of `laneFor` (FACTS.md records that trap; the test that pins it moves with the rule). Ends with the digest lane driving a production pass — the "has run in production once" precondition every retirement below stands on. 3. **1.3 — Sweeps and arbiter retire.** Arming becomes tree authoring: one `armLaneAction(lane, {operations, channels})` writes `autoQueue[lane].root`. The ten `sweepEnabled` / `sweepKinds` / `sweepChannels` / `order` / `reach` / `weight` fields migrate on read, then are deleted with their sanitizers and forms. `digestSweep.ts`, `backfillSweep.ts`, `sweepPreview.ts`, `sweepRecency.ts`, `lib/sweepPlan.ts`, `arbiter.ts` and `arbiterWork.ts` go. 4. **1.4 — Pause is lane state.** `AutoQueuePolicy.held`, behind the unchanged `isGateHeld` / `withGateHeld` API, with the four legacy fields read as a migration for one release. All 13 callers unchanged; the transcription intent-vs-pool asymmetry stays. 5. **1.5 — One work list per lane in the snapshot.** `generateChannelSnapshot` writes `backfill.download` and `backfill.transcription` entries whose `ids` are the union `defaultBucketsForPolicy` draws today, so `snapshot.backfill[op]` (typed `operations`, disk key unchanged) covers every operation. **There is no `buckets.undownloaded` or `buckets.untranscribed`** — the earlier draft of this step named fields that do not exist; download's list is `snapshot.undownloadedIds` ∪ `buckets.partialDownloads` and transcription's is `downloadedNoTranscript` ∪ `failedListed`. The storage/cleanup buckets (`wrongFormatAudio`, cleanup sizes) remain buckets — they are not operations. Deleted by the end: two sweeps, the arbiter and its work projection, one batch, two planners, ten settings fields with their sanitizers and forms, and `SweepLane`/`ArbiterBar` from `/operations`. `autoRunner.ts` is GENERALIZED, not deleted — it is the loop everything else folds into. Kept verbatim: the seven "must not be lost" invariants. ### Interlude — relocate channel media (between Phase 1 and Phase 2) Not a phase of this plan, but it runs before Phase 2 and its design is in [`relocate-channel-media.md`](relocate-channel-media.md). `/home` is full (100 %, 6.9 G free) and a channel's `data/` needs to be able to live on another drive. The mechanism is a symlinked `channels//data`, a `config.dataDir` record written only by the job, and four guards so an unmounted drive never reads as "nothing downloaded". It touches dispatch (`streamCommand`, `autoRunner`, `operationBatch`) but changes no dispatch semantics, and it is independent of the contract work below. The second interlude item is [`channel-priority.md`](channel-priority.md): one ordered-tier channel priority model (focus / normal / low / paused, a focus by site or by list) that replaces `excludeFromSync`/`excludeFromBuild` on `/channels` and COMPILES to the four `autoQueue[lane].root` trees, with paused as one filter in `listChannelMeta`. Standalone plan, sequenced as Phase 1.5: after relocate (a focus means more downloading, onto a disk that must have room), before Phase 2 (which touches none of `autoQueue`, `syncScheduler` or `/channels`), branched off this branch's tip so it never edits the lane trees beside Phase 1. ### Phase 2 — The contract: one `ArchiveReader` (5 slices) **SHIPPED 2026-09-12** on branch `integrate/2026-09-storage-priority`, tip **`bd3d4ec`**, unmerged — all five slices, merged in the order their reviews cleared (`7f86aef` S1, `b7a351c` S2b, `858aabc` S2c, `9026007` S3, `bd3d4ec` S2a). The record — every slice's sha range, every divergence and every gate number on the combined tip — is [`one-core-phase-2.md`](one-core-phase-2.md#phase-2--shipped-2026-09-12); the anchors are [`FACTS.md`](FACTS.md#one-core-phase-2-verified-2026-09-12-branch-integrate2026-09-storage-priority). **One wire change in the whole phase** — `/digests/*` and `/duplicates.json` gain CORS on a site deploy — and the architecture test's allow-list is still eleven while `"components"` joined `FORBIDDEN.lib`. S0-pause is deferred to the next release and `cues.mjs`'s `tsx` adoption to Phase 5; both reasons are in the record. Slice plan: [`one-core-phase-2.md`](one-core-phase-2.md). Corrected 2026-09-12 after a survey; the original wording is kept in git. 1. **`common/lib/archive/`**: `contract.ts` (re-exports the `CONTRACT` object from phase 0 plus the walk as *code*: `corpusUrl`, `manifestUrl(layer, slug, base?)`, `pageUrl`, `rootFileUrl`, `ROOT_FILES`, `shipsPwa(site)`, and the one hub entry type), `reader.ts` (the `ArchiveReader` interface — mcp's 18-member `ShardSource` verbatim, **not** four methods, and **no** `record(layer, slug, id)`: a per-record fetch is a bench regression — plus the HTTP transport, browser-safe, zero node imports), `reader-fs.ts` (node — the only `node:fs` file), `reader-hub.ts` (federation). The implementation is `mcp/src/source.ts` moved, caches included; mcp's `ShardSource` becomes a re-export. `config.dataDir` is **not** `reader-fs`'s concern — `LocalSource` reads a composed public dir; the resolver belongs to `cues.mjs`, below. 2. **Every reader uses it.** *Eight* component caches, not four — `common/components/{transcript,subs,posts,digest,summaries,stats,duplicates,aliases}Cache.ts` — become thin memo wrappers over the HTTP reader; `export/app/lib/offlineCache.ts` keys its cache off `contract.ts` paths (this also fixes the open `duplicates.json` offline gap) and the service worker's hand-written layer list is pinned to `CONTRACT.layers` by a test. The deliberate copy in `searchIndex.worker.ts` stays (a worker cannot import). 3. **`cues.mjs` resolves `dataDir` and refuses to fall through.** umtool's cue resolver replicates the editor's reachability check in plain `.mjs` and throws on an unreachable channel instead of silently cutting from HTTP cues. It does **not** adopt `tsx` or import the reader in this phase — that is Phase 5, and the change list is recorded in the slice note. 4. **`shipsPwa()`, the `_headers` block and the hub entry type are defined once** in `contract.ts` / `compose-*`. The generated `_headers` also gains CORS for `/digests/*` and `/duplicates.json` (a wire change, its own commit). 5. **One search pipeline.** The ranking, windowing and snippet logic in `mcp/src/search.ts` and `common/components/searchPipeline.ts` become one `common/lib/search/` module with no React and injected fetchers; `SearchSessionContext` and the MCP tools both call it. `mcp/bench/bench.ts` is the regression gate, and `lib/ → components/` joins the architecture test's forbidden edges. Deleted: every hand walk of the shard scheme but the worker's, mcp's private reader, one of two search pipelines. Verified against the live contract: `curl https://jeralyzer.pages.dev/corpus.json` before and after, byte-identical output from `compose-site` on the fixture corpus. ### Phase 3 — Views in the core, editor as shell (4 slices) > **COMPLETE 2026-09-24, `main` @ `e172749b`.** Slice 1 shipped 2026-09-15 (`838da4a`). > Slice 2 shipped 2026-09-23 (merged as `8d6e84f6`), and slice 4a (settings.json only) the same > day (`ae2fa5a9`). Slice 3a (channel row + job in flight) and slice 4b (`site.json`, channel > `config.json`, sidecars) shipped 2026-09-24 (`3241fed2`, `ef88ac4d`). Release 4 shipped the > same evening: slice P (/channels rack polish, `77a32de2`), slice W (one write idiom, > `ddad13f4`) and slice 3b (the `VideoPanel` split, `e172749b`). Release 4 is not yet live > on :3001. Phase 4 is next (done 2026-09-28, below). > Records, gates and what is still open: [`one-core-phase-3.md`](one-core-phase-3.md). 1. **`common/views/`**: move `buildActiveJobs`, `buildWorkers`, `lanes.ts`, `channelFlow.ts` + `stageStatus.ts`, the widget sync builder and `pulse` out of `editor/app/**` into pure functions over (snapshot, registry, settings, pool). Each keeps its unit tests; the architecture test forbids `views` from importing React or Next. 2. **One polling route.** `/api/view/[name]` serves any view; the seven thin routes become rewrites to it (retired routes redirect). Worker protocol (`/api/worker/*`), the external cron tick, file serving and the four `/api/test/*` routes stay. *As shipped (2026-09-23): EIGHT routes, not seven, became `rewrites()` to the one route — API paths are rewritten, never redirected, so method, body and `?rev=` pass through. No `names=a,b` batch route: cadences stay per call site, and Phase 6's SSE is the batch.* 3. **One drawing per noun.** Channel row: `channels/components/ChannelsTable.tsx` with a column set replaces the dashboard's and `ChannelWorkTable`. Job in flight: the `JobRowView` renderer from slice 8c with a `variant` replaces `RunningJobsList`, `LaneStrip`, `InFlightList`. Channel pipeline: keep `flow/*` stations (registry-derived, with the hand-listed bookend chores the IA doc protects) and delete the eight `stages/*` cards' duplicated status logic, leaving each stage's *body*. Widget: `MonitorWidget` composes the same components with `compact`; `widget/builder` keeps laying out those components. `VideoPanel.tsx` (1586) splits into per-operation panels the registry already enumerates (`videoOperationPanels.ts`). *As shipped for the channel row and the job in flight (slice 3a, merged `3241fed2`, 2026-09-24): **the channel row** is one pure builder, `common/views/channelRow.ts` (`buildChannelRowView` → `ChannelRowView`, which carries no `config`), over the same per-channel counters the actionable census uses (`common/views/actionableCounts.ts`). Columns are a **registry looked up by id**, not column objects passed as props: a server shell cannot pass `cell: (row) => ReactNode`, and a server component that imports a value from a `"use client"` module gets a client reference, not the value — so the ids and the `RACK_COLUMNS` / `DASHBOARD_COLUMNS` / `WORK_COLUMNS` presets live in the plain `channelColumnPresets.ts` and the cells in the client `channelColumns.tsx`. The rack split into `ChannelsRack` (chrome: focus, volume and instrument bars, scroll region, selection deck) and the shared `ChannelsTable`, which the dashboard and `ChannelWorkTable` (now a server shell) also draw; `components/dashboard/ChannelsTable.tsx` and `ChannelWorkTable`'s `Row` are deleted. **The job in flight** is one `jobs/components/JobRow.tsx` with `variant` `table` | `card` | `compact` and one `JobRowActions`, drawn by `JobsTable`, `RunningJobsList`, the widget's `ActiveJobsStrip` and `InFlightList` — the last through a new pure adapter, `fromInFlight`, for which the runner now records each download unit's registry `jobId`. The widget's private row, bars and glyph tables are deleted. **`LaneStrip` keeps its lane line**: a lane is not a job, so only its runner job's buttons became `JobRowActions` — the "replaces … `LaneStrip` …" above was wrong. The `stages/*` status logic (nine files, not eight) and the `VideoPanel.tsx` split (1,839 lines, not 1,586) are slice 3b, not shipped.* *As shipped for the channel pipeline and the video page (slice 3b, merged `e172749b`, 2026-09-24):* - ***The stage half was already done by the flow work, and nothing was changed.*** *The nine `stages/*` files hold no duplicated status logic. `computeStageStatuses` (`common/views/pipeline/stageStatus.ts:192`) and `computeChannelFlow` (`channelFlow.ts:220`) are the one fold. Each is called once, in `channels/[slug]/page.tsx:256-264` and `:308-315`. Every stage card takes derived id lists and sums as props and keeps only its form state.* - ***The video page's registry split already existed.*** *`videoOperationPanels.ts` and `OperationPanel.tsx` render digest, diarization and attribution. What was still inline in `VideoPanel.tsx` was the video CHORES, which no registry entry owns. They were split verbatim, one module per card: 15 modules under `videos/[id]/components/cards/`, plus `Heading.tsx` and `videoFiles.ts`, with `VideoNavStrip` and `PipelineStatusStrip` placed beside the panel.* - ***`VideoPanel.tsx` is the assembly, 1,839 → 455 lines, not ~300.*** *The `VideoPanel()` function alone is 360 lines of gates, summaries and the fifteen `PipelineStageCard` wrappers. The rule was to keep order and gating exactly. Every card's gate and props differ, so the literal JSX was kept rather than driving it from a list. `lib/videoChoreCards.ts` is that list: the fifteen chores in render order, each with why it is a chore and not an operation. Its test reads `VideoPanel.tsx` as text and pins the rendered order and the `./cards/` imports to the list. Labels are unchanged: 126 entries, identical md5.* 4. **One settings writer and one schema.** Adopt one schema library (recommend `zod`; veto if unwanted) for `settings.json`, `site.json`, channel `config.json` and every sidecar. `lib/settings.ts`'s ten `sanitizeX` + eight `defaultX` + ~40 clamp constants become one schema with defaults; `getSettings` / `writeSettings` stay the one reader/writer. A single `saveSettingsBlock(block, patch)` server action replaces the per-block writers spread over six files; forms stay where the IA put them. The sidecar `X.ts` / `X-server.ts` pairs become one `sidecar(name, schema)` declaration each (read, atomic write, mtime freshness, the `transcript..` naming guard). Channel `config.json`'s mutable sync state (`lastSyncedAt`, `lastFullSweepAt`, …) is declared as such in the schema; splitting the file is *not* in scope (disk is frozen). `settings.json.example` and the docs' key table are generated from the schema. *As shipped for `settings.json` (slice 4a, 2026-09-23): **zod adopted** — the veto is spent, one dependency in `common/`, kept out of every client bundle. The writer is `saveSettings(patch: Partial)` (one-level-deep merge, `editor/app/settings/saveSettings.ts`), NOT `saveSettingsBlock(block, patch)`: the channel-priority action writes `channelPriority` and the four `autoQueue` roots in one write, which a one-block signature would split. It is a helper the actions call, not a server action. `SETTINGS.md` + `settings.json.example` are generated. `site.json`, channel `config.json` and the sidecars are slice 4b.* *As shipped for `site.json`, channel `config.json` and the sidecars (slice 4b, merged `ef88ac4d`, 2026-09-24): all three are on zod, following 4a's pattern of `settingsField(coerce)` over the parser that already existed, with no `.default()` and no `.passthrough()`. **`site.json`** is `common/lib/siteSchema.ts`, and `site.ts` is left holding the I/O and the resolvers. **Channel `config.json`** is `common/lib/channelConfigSchema.ts`. Its coercions stay zod-free in `channelConfig.ts` (`CHANNEL_CONFIG_COERCIONS`), because six `"use client"` modules value-import that file; the schema wraps the same functions. The mutable sync state is declared as `CHANNEL_SYNC_STATE_KEYS` (`lastSyncedAt`, `lastFullDownloadAt`, `lastFullSweepAt`), and the file is not split. There is one reader (`readChannelConfigFile`) and a **strict** writer (`writeChannelConfig`), which throws on a value that is not a channel. There is also one read-modify-write patcher, `patchChannelConfig(paths, slug, patch, {unset})`, which every writer after creation uses except two whole-config fallbacks. `updateConfigField` is deleted. **Eight sidecar pairs (nine files)** are each one `sidecar(name, sidecarField(coerceX))`, which provides the read, the atomic write, the remove and the `transcript..` naming guard (a throw at declaration). **mtime freshness was not built**, because no reader consumes it. **`SITE.md` and `CHANNEL.md` are generated** (`common/bin/file-schemas-docs.ts`, with `--check`). **One JSON reader and one atomic writer** live in `common/lib/jsonFile-server.ts`. That module gives each write a unique temp name, chains writes per absolute path, and pins its state on `globalThis`, so one server has one chain even when Next loads the module twice. It replaced seven private `writeJsonAtomic` copies and the inline tmp writes on these files. Not done: **16 JSON write sites in 13 files are still on the per-pid temp name** (the record said "14"), including `maybeMissingStore` and `rosterStore`, which are per-channel files written from several lanes. They are owed, and listed in the slice record. The chain is per process, so a CLI running beside the editor is not covered. `savedVideo`, `clipWindow`, `posts`, `transcripts` and `digestContext` stay outside `sidecar()` by name; only their JSON writes moved to the shared writer.* *As shipped for the remaining writers (slice W, merged `ddad13f4`, 2026-09-24): `common/lib/jsonFile-server.ts` gained `writeFileAtomic(file, string | Buffer, {mkdir, mode})` and `copyFileAtomic(src, dest, {mkdir})`, and `writeJsonAtomic` is now `jsonText` → `writeFileAtomic`. All three use the one per-path chain on `globalThis` and the one unique temp name. **26 sites were folded**, byte for byte: 19 JSON write sites (14 of the 16 above, including the roster and maybe-missing; the two failed-transcriptions writes are text. Plus metadata-scan, `autoQueueState`, the two cue normalizers and the remark literal) and 7 text and binary ones (failed transcriptions ×2, the playlist, the 0o600 cookie jar, the saved-video cross-device move, the release cut, the VTT promote). **Both per-module-copy write counters are deleted** (`metadataScanStore`, `autoQueueState`). A failed write now removes its temp file. Left by name: `buildIndex.ts:971` (the streaming page writer) and `buildStats.ts:220`, which need restructuring rather than a fold; `transcode.ts:31` and `transcribeOne.ts:142`, which name an external process's output file; and `transcribeOne.ts:173`, which writes the remote transcript directly with no temp. `phase3-writers-numbers.ts`: 315 samples, all equal to the old idiom's bytes, diff empty.* Deleted: six payload builders from the app, eight API routes, two channel tables (the dashboard's, and `ChannelWorkTable`'s own `Row` — slice 3a), three job renderers (the widget's private `JobRow` and its bars, `RunningJobsList`'s card and `InFlightList`'s line now draw `JobRow`; `LaneStrip` keeps its lane line — slice 3a), nine stage status derivations (slice 3b), ~1,400 lines of hand sanitizers. *As shipped: the nine stage derivations were already gone, because the flow work had moved them into `computeStageStatuses`. 3b deleted nothing of that kind. What it removed from `VideoPanel.tsx` was 1,424 lines, moved into card modules. Slice W deleted the two write counters and the private tmp + rename code at 26 write sites.* ### Phase 4 — CLI, entry points, config, docs (3 slices) > **DONE 2026-09-28** (release 11): slice 3 checkpoints A (`cb9d02b2`: `archilyzer doctor`, `run`, `mcp`, every bin a > subcommand, generated `ENVIRONMENT.md`, `PUBLISH.md` absorbing the deploy docs) and B (`eb28a341`: test-only env vars > `E2E_`-prefixed, the harnesses read `ports.mjs`); the build-mode stub dropped (O6c). The optional SETUP/PLAN.md > consolidations were not done. Record: `release-11.md`, STATE "release 11". > > *Was:* **Next** (after the exports-off and Rumble releases, see > [`one-core-phase-3.md`](one-core-phase-3.md#next--phase-4)). Starting points, checked on > `e172749b`: > - `editor/app/sites/lib/buildDeployCore.ts` is 578 lines and imports nothing from > `editor/**` (only `node:*`, `@aws-sdk/*`, `yt-dlp-transcript-common/*`). Its two callers > are `sites/lib/buildAction.ts` and `deployAction.ts`. Item 1 has no editor import to cut. > The `@aws-sdk/client-s3` and `@aws-sdk/lib-storage` dependencies are in > `editor/package.json` only, so they move to `common/` with it. > - `common/bin/settings-example.ts` and `common/bin/file-schemas-docs.ts` both already carry > `--check`. > - `PUBLISH.md` does not exist yet. 1. **`buildSite` moves to the core.** `editor/app/sites/lib/buildDeployCore.ts` → `common/publish/build.ts` (`buildSite(id, opts)`, `deploySite`, `buildAll(mode)`, `composeHub`, `composeHomepage`). The editor actions call it as jobs; `docker/build-site.sh` and `publish-site.sh` call the CLI; `build` / `build:nodata` twins become one script with `--nodata`; `build:hub` gets a CLI and an editor entry (it has neither). 2. **`common/bin/archilyzer.ts`**: `index`, `compose site|hub|homepage`, `build site |all`, `deploy site `, `sync tick`, `run [ids…]`, `mcp`, `settings example` (*already exists as `common/bin/settings-example.ts`, with `--check`, since Phase 3 slice 4a — becomes a one-line subcommand*), `doctor` (the env/binary/port checks now scattered across `paths.ts`, umtool's doctor and `worktree.mjs`). Existing bins become one-line subcommands and are removed from `package.json` scripts once nothing references them. Port defaults exist once (`scripts/worktree.mjs` today says it "mirrors" three other places). 3. **Config and docs.** Env vars: `paths.ts` stays the one override surface; the fifteen test-only vars get an `E2E_` prefix and live in `playwright.config.ts`; the docker `ARCHILYZER_*` set stays separate (different process) but is listed in the same generated table; `PARALLEL_TRANSCRIBE_LIMIT` is removed from the docs. Docs by audience: `README.md` (use an archive), `SETUP.md` (run your own — absorbs `SCHEDULED_SYNC.md` and `WORKTREES.md`), `PUBLISH.md` (absorbs `DEPLOY_DOCKER.md` and `DEPLOY_CLOUDFLARE.md`), `RUNNING_IN_DOCKER.md`, `CONTRIBUTING.md`, `AGENTS.md`; stale claims (whisper as the only engine, transcode as a stage) corrected; `PLAN.md`'s phase table becomes a pointer to `STATE.md` so status has one owner. ### Phase 5 — Projects join the core (3 slices) 1. **`common/projects/`** holds only the *model*: `Project` (`kind`, `dir`, manifest, state, open decisions, build) and a `ProjectKind` registry mirroring `OperationDescriptor` (`id`, `scaffold`, `build`, `verify`, `surfaces`). umtool's `lib/projects/*` (report, scaffold, decisions, folders, the LMDB project index with its "if a value exists only in the index, that is a bug" rule) moves here; `umtool/lib/paths.mjs` roots (`REPORTS_ROOT`, `SONG_REPORTS`) become `paths.ts` entries. 2. **Two kinds registered, both from umtool's tree.** `report-video` is `umtool/report-to-video/` (moved in Phase 0; now reading cues through the phase-2 reader and taking `siteOrigin` from the project manifest — `umtool/lib/archive.ts`'s hardcoded origin and channel go). `song` is `umtool/song/`, a kind whose scripts are invoked by the registry, not by relative path. `umtool/lib/corpus.ts` is renamed `pitch.ts`. The core never imports a kind; umtool registers its kinds at boot, the way the editor's `instrumentation.ts` arms runners. 3. **umtool becomes the projects console**: a shell over `common/projects` and `common/views`, on the same `ui` components and the same nav rule; its `doctor` folds into the CLI's. The `.ts`/`.mjs` twin files that exist only so `bin/umtool.mjs` can run under plain node go away when that bin becomes `archilyzer project …` under `tsx`. The editor's one cross-app coupling (`UMTOOL_URL` on the video page) becomes a project link the registry knows how to make. Deleted: a second project model, a second doctor, a second nav rule, a second FACTS file's worth of quirks that stop being true, the hardcoded archive origin, ~20 twin files. ### Phase 6 — The daemon (3 slices, last) 1. **A `Core` interface** with one in-process implementation: everything `editor/instrumentation.ts` arms (reaper, heartbeat, pause restore, arbiter) plus the verbs the server actions call (start/stop/pause, run operation, build site). No behaviour change; the actions now go through the interface. 2. **`archilyzer serve`** hosts that implementation: the worker protocol routes, the external tick, `/api/view/*` and a small control API over HTTP/SSE. `instrumentation.ts` becomes "connect to `CORE_URL`, else boot in-process" so `pnpm dev:editor` is unchanged. 3. **Consequences cashed in.** `playwright.config.ts` runs one daemon per worker and `fullyParallel: true` (the four `globalThis` singletons were the stated reason it could not); "do not boot a second editor against the real corpus" becomes "one daemon per corpus", which the daemon enforces with a lock file; the docker `editor` service runs the daemon and the console as two processes of one image; `ARCHILYZER_IDLE_BOOT` is a daemon flag. The e2e queue lock stays for the shared-port case. ## What must not be lost Read before any slice; each was paid for once. `plans/unified-operations-model.md` "What must not be lost" (reachable ≠ needs-media; a zero limit is a hold; re-derive from disk every pull; one job per channel; the `never` default on `candidateAction`; distinct queue keys are the only concurrency mechanism; derived sidecars are not disk-gated). `plans/FACTS.md` "Naming hazards" (`SUB_FILE_RE` claims `transcript..`; `summaries` is the metadata index; `report` means three things), `band.ts` carries no directive and no value import, `laneFor` presence is load-bearing until phase 1 step 4 changes the rule, and the AGENTS.md container facts (glibc only goes forward, `PARAKEET_GGML_VULKAN`, the `vulkan:` boot line). Directory name ≠ metadata id on ~14.5% of the corpus. ## Verification Per slice, in this order; nothing ships on a paraphrase of a number. - `pnpm -r exec tsc --noEmit` and `pnpm --filter yt-dlp-transcript-common test` (the architecture test included). `pnpm test:scripts` for report-to-video (glob repointed at `umtool/report-to-video/*.test.mjs` in Phase 0); `pnpm --filter yt-dlp-transcript-mcp test` and `bench` for phase 2. - **Numbers do not move.** For phases 1 and 3: an offline `tsx` script over the real corpus dumps every `ChannelSnapshot` count, every band figure and every view payload before and after; the diff must be empty or explained in the slice's plan note. Never a second editor against `transcripts/`. - **The contract does not move.** For phases 0, 2 and 4: `compose-site` over `editor/test-transcripts/` before and after is byte-identical; `curl` of the live `corpus.json` and `llms.txt` still parses through the new `contract.ts`; the MCP smoke bench passes against `TRANSCRIPT_SITE_URL=https://jeralyzer.pages.dev`. - **e2e**, detached behind the queue lock (~24 min; use `setsid nohup` and a log monitor, not a foreground wait). Retired routes are asserted to 307 in `nav.test.ts` / `next.config.ts`; the 21–22 known-failing base specs in FACTS.md are not chased. - **Run the thing.** Phase 1: the arbiter dispatches one real unit per lane on the corpus and survives a restart. Phase 4: `archilyzer build site jeralyzer` produces the same `out/` as the editor's Build button. Phase 5: `archilyzer project build` regenerates an existing report video's manifest identically. Phase 6: two editors against one daemon, and `e2e` at `workers: 4`. ## How to run this Keep the repo's cadence: one plan file per slice under `plans/` (this document is the umbrella), each slice committed with its measurements in the commit body, `STATE.md` updated before context is cleared. The first concrete slice to write is **Phase 0** — it is one session, deletes only dead weight, and its architecture test is what makes every later deletion cheap to verify. ## Phase 0, as shipped Branch `one-core/phase-0`, 2026-09-07, six commits (`df5eb48` → `1691c4f`). Every item is a deletion, a move or a guard; nothing on disk in a corpus changed and nothing on the wire changed. **report-to-video is under umtool.** `umtool/report-to-video/`, package `umtool-report-to-video`. Behaviour, bins and exports are untouched — `cues.mjs`'s `REPO_ROOT` is still two levels up, so `DEFAULT_CHANNELS_DIR` is unchanged. The survey undercounted the import sites: there were **eight**, not four (add `components/projects/ClaimBench{,Page}.tsx`, `lib/report/manifest.mjs`) plus three path references and `Dockerfile`'s manifest COPY. **The layering is a test.** `common/architecture.test.ts`. Back-edges went **17 import lines / 16 distinct edges → 13 / 12**; the four burned down were all type-only and moved into a new `common/lib/autoQueueTypes.ts` (the persisted auto-queue shape, plus `AutoQueueKind`), which `jobs/autoQueuePolicy.ts` and `jobs/autoQueueState.ts` re-export so no import site outside `common/lib` changed. The allow-list can only shrink: a stale entry fails the suite too. Twelve remain, each with its reason in the file — nine of them wait on phase 1 (the registry's `run()` closures are THE structural back-edge), one on phase 3 slice 4 (the auto-queue sanitizers are settings-schema code), one on phase 3 slice 1 (`StreamActionResult` becomes a view-model). **The surface is declared.** `common/package.json` has an `exports` map; `mcp/package.json` declares `yt-dlp-transcript-common` (it had been resolving by hoisting). One trap paid for here: `"./components/*": ["./components/*.tsx", "./components/*.ts"]` **type-checks and does not build** — Turbopack takes the first entry of a fallback array and stops. The 25 `.ts` modules under `components/` are listed explicitly instead, and a new one needs a new line. **Deleted**: root `sites/`, `common/bin/migrate-to-sites.ts` (the controller stays — the Sites page's Migrate button calls it), `create-archives.sh`'s commented tail, `export/.compose-cache/` (`.r2-staging/` deliberately kept), and `PARALLEL_TRANSCRIBE_LIMIT` from three doc tables. **Moved**: the three bake-off harnesses (3,828 lines, 58% of `common/bin/`) to `plans/tools/`. That needed one new file — `common/bin/_lmdb.ts` — because `plans/` is not a workspace package and a bare `import "lmdb"` resolves from the importing file upward. **One `pageFileName`, one `CONTRACT`.** The page-shard name existed **six** times, not three: `lib/manifest.ts` ×3 plus `stats.ts`, `posts.ts`, `digests.ts` (a seventh copy in `components/searchIndex.worker.ts` is left alone — a bundled worker). `CONTRACT` in `lib/corpus.ts` owns eight values and the layer names; the seven existing constants are re-exports of its fields. Proved on the wire by composing a fixture site before and after: 16 public files and 7 index files, **identical apart from `generatedAt`** (and the archive zip's entry mtimes; its contents diff clean). **What phase 1 should read first**: the allow-list in `common/architecture.test.ts`. It is now the shortest accurate statement of what is still tangled, and every entry names the slice that untangles it. **Verification, in full**: `tsc --noEmit` clean in common, editor, export, mcp, homepage and umtool after every item; `pnpm --filter yt-dlp-transcript-common test` **876/876** (874 + the 2 new architecture tests), `pnpm --filter yt-dlp-transcript-mcp test` **205/205**, `pnpm test:scripts` **71 passed / 1 skipped**; a real `pnpm --filter editor exec next build` (the only test an `exports` map has); the compose byte-diff above; and the editor e2e **511 passed / 0 failed in 23.8 min**. The e2e had to run from a `git worktree` — the primary checkout's untracked `editor/content` symlink panics Turbopack in dev, on `main` as well as here. See `plans/STATE.md`.