commit 89a009d84143e0357808f5d6a46dad9987f4df6d
parent 296dbe81fe4e41f33c00f0d982254f9c2d319fcf
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Mon, 14 Sep 2026 16:39:57 -0400
plans: one-core Phase 3 slice 1 — common/views/, the port and the guard
Slice-level plan for the first Phase 3 slice only: every pure view-model
moves into common/views/ and takes its singletons as arguments; the editor
keeps thin shells at the old paths. Burns the last allow-list entry.
Sub-slices A (serial) then B and C (parallel) then D (serial).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
1 file changed, 327 insertions(+), 0 deletions(-)
diff --git a/plans/one-core-phase-3.md b/plans/one-core-phase-3.md
@@ -0,0 +1,327 @@
+# One core — Phase 3, slice 1: `common/views/`
+
+## Context
+
+`plans/one-core.md` §Phase 3 is the umbrella: views in the core, editor as shell, four
+slices. This is the slice-level plan for **slice 1 only** — decided with the operator
+2026-09-14. It starts from `main` after the `/channels` rack landed (`1bc8765`, record in
+`editor-channels-rack.md`); the O2–O9 rollout proceeds in parallel and S0-pause still waits
+on its boot. Slice 3 (which rewrites `ChannelsTable.tsx` again) is not planned here; landing
+the rack first is what keeps that slice a rebase-free rewrite of one file.
+
+The slice: every pure view-model the editor computes from live state moves into
+`common/views/`, takes its singletons as **arguments**, and is proven pure by the layer
+guard. The editor keeps thin shells at the old paths with the old names, so no consumer and
+no route changes.
+
+## Why (what the code says)
+
+Surveyed 2026-09-14 against `main` @ `1bc8765`. Trust these over re-deriving them.
+
+- **The guard is a relative-specifier grep over an explicit list.**
+ `common/architecture.test.ts`: `FORBIDDEN` at `:31` (`lib`, `jobs`, `components` rows),
+ `ROOTS` at `:45` (explicit; a `views/` dir is invisible until added), `ALLOWED` = 11
+ entries, the last being `components/StreamActionLog.tsx -> jobs/streamCommand` ("becomes a
+ view-model in common/views/ (phase 3)") — this slice **burns it** (11 → 10). `IMPORT_RE`
+ matches static imports; `:137` skips every non-relative specifier, so "no react/next in
+ views" needs a NEW bare-specifier check. The stale-entry test forces the burn and the type
+ move into one commit.
+- **Every candidate file imports common by package name** (`yt-dlp-transcript-common/...`);
+ common's own modules import each other relatively (`controller/autoRunner.ts` has 20
+ `../lib/` imports). Moved files MUST be rewritten to relative form or the layer guard never
+ sees an edge from `views/`.
+- **Registration is explicit twice.** `common/package.json` exports wildcard per dir — add
+ `"./views/*": "./views/*.ts"` (a string, never an array: FACTS:3213; `*` spans `/`,
+ precedent `lib/archive/*`). The test script (`:44`) is a brace list at two depths — add
+ `views` to both. tsconfig needs nothing.
+ `common/controller/noCorpusWalkInRenderPaths.test.ts:28` scans `editor/app` only — widen
+ to `common/views` in the same commit.
+- **The never-construct rule.** `editor/app/api/pulse/route.ts:36-53`: `getRegistry()` /
+ `getWorkerPool()` / `getScheduler()` CREATE singletons and `/api/test/invalidate-cache`
+ clears them between specs (was ~16 flaky specs), so pulse reads `globalThis.__ytt*__`
+ directly. `getAutoRunnerStatus` also constructs (`controller/autoRunner.ts:232-240,284`).
+ A view that takes its singletons as arguments makes the rule structural. That is the
+ slice.
+- **`React.cache` keys on `paths` identity** (`editor/app/lib/requestCache.ts:24-26`; the
+ file states why it must stay in the editor). Views take briefs as a parameter.
+- **Already pure, move as renames:** `channelFlow.ts` (582), `stageStatus.ts` (628),
+ `flow/tone.ts` (41), `pipelines/band.ts` (275, zero imports), `pipelines/buildBands.ts`
+ (248), `channels/lib/channelGroupSections.ts` (321), `jobs/jobRowView.ts` (94),
+ `jobs/jobRows.ts` (331), `components/lanes/laneState.ts` (79),
+ `widget/lib/builtInPresets.ts` (63), the pure half of `operations/channelPriorityView.ts`
+ (119).
+- **Not pure yet:** `jobs/active/buildActiveJobs.ts` (461: fs log tail `:143`, `diskGate`
+ `:347`, `readJobMeta`/`readChannelStat`, `Date.now()` `:233,:271`, five getters,
+ `getAutoRunnerStatus` `:395`); `workers/buildWorkers.ts` (58: types imported from the
+ `"use client"` `WorkersView.tsx:16-66`, `readWorkerDefaults` sync fs);
+ `operations/lanes.ts` (131) + `operations/status.ts` (179: `readAutoQueueState` read 4× at
+ `:122`); `api/widget/sync/route.ts` (331: builder + type INSIDE the route with
+ `NextResponse`; `app/page.tsx:20` imports the builder out of a route file — the smell);
+ `api/pulse/route.ts` (161); `scheduler/status.ts` (53: imports `heartbeat.ts` → `runTick`,
+ a runner).
+- `ArchiveReader` (Phase 2) is the pattern, not the port: editor views read live state, not
+ a published archive.
+
+## Layout
+
+```
+common/views/
+ inputs.ts the port — LiveInputs / ObserveInputs / RegistryReader / PoolReader; TYPES ONLY
+ streamAction.ts StreamActionView (the burn); zero imports; StreamActionResult is assignable to it
+ jobRowView.ts ← editor/app/jobs/jobRowView.ts (types only; imported by "use client" files)
+ jobRows.ts ← jobs/jobRows.ts + jobRows.test.ts (13)
+ laneState.ts ← components/lanes/laneState.ts + test (4); imports only ./pipeline/tone
+ activeJobs.ts ActiveJobsPayload/ActiveLaneView/DiskStatusView + buildActiveJobsPayload(inputs),
+ liveJobRows(inputs, filter), rowsFromJobsPage(page), stuckJobIds(inputs)
+ workers.ts WorkersPayload/WorkerView/WorkerTask (out of WorkersView.tsx) + buildWorkersPayload(inputs), sync
+ autoQueueLanes.ts ← operations/lanes.ts; buildAutoQueueLanes(inputs), sync
+ channelPriority.ts PriorityView, pendingByLeafFromCounts, laneFocusSummary — the pure half of
+ channelPriorityView.ts (readPriorityView stays in the editor)
+ autoQueueStatus.ts ← operations/status.ts; buildAutoQueueStatusPayload(inputs), sync
+ widgetSync.ts WidgetSyncPayload + buildWidgetSyncPayload(inputs) ← api/widget/sync/route.ts:27-327
+ schedulerStatus.ts ← scheduler/status.ts; buildSchedulerStatusPayload(inputs), sync
+ pulse.ts PulsePayload + computePulse(observe) ← api/pulse/route.ts:20-122
+ builtInPresets.ts ← widget/lib/builtInPresets.ts (its test STAYS in the editor: it imports ./config)
+ channelGroupSections.ts ← channels/lib/channelGroupSections.ts + test (16)
+ pipeline/ the only nest — these five import each other
+ band.ts ← components/pipelines/band.ts
+ buildBands.ts ← components/pipelines/buildBands.ts + test (15)
+ stageStatus.ts ← channels/[slug]/lib/stageStatus.ts + stageOrder.test.ts (2)
+ channelFlow.ts ← channels/[slug]/lib/channelFlow.ts + channelFlow.test.ts (12)
+ tone.ts ← channels/[slug]/components/flow/tone.ts
+```
+
+Payload TYPE names and wire shapes unchanged everywhere; only paths move.
+`common/jobs/syncScheduler.ts`'s `buildScheduleView` stays in `jobs/` (views may import
+jobs).
+
+### The port
+
+`views/inputs.ts`, types only. `JobRegistry` is not an exported class, so use
+`ReturnType<typeof getRegistry>` via `import type`, as pulse does today at `route.ts:56`:
+
+```ts
+export type RegistryReader = Pick<ReturnType<typeof getRegistry>, "list" | "get">;
+export type PoolReader = Pick<WorkerPool, "summary" | "isPaused" | "canStopPartial">;
+export type LiveInputs = {
+ paths: Paths; settings: SiteSettings; registry: RegistryReader;
+ scheduler: Scheduler; pool: PoolReader; now: () => number;
+};
+export type ObserveInputs = {
+ registry: RegistryReader | null; scheduler: Pick<Scheduler, "queues"> | null;
+ pool: PoolReader | null; snapshotGeneration: number; settingsMtime: number;
+ changelogMtime: number; digest: (s: string) => string;
+};
+```
+
+### The one constructor
+
+New `editor/app/lib/liveInputs.ts` (~40 lines): `liveInputs()` calls the five getters +
+`now: Date.now` — the only editor file allowed to call all five for a view;
+`observeInputs()` reads `globalThis.__ytt*__` (the `declare global` block moves here from
+`pulse/route.ts:54-61`; `__yttSnapshotScheduler__?.generation ?? 0` as a number — its type
+is not exported), the two `statSync` mtimes, and `digest = sha1/base64url`. Per-view extras
+are assembled in each noun's shell, not here, so B and C never edit this file. It imports
+`getChannelBriefs` from `./requestCache` (three other importers; do not absorb it).
+
+### The guard change (`common/architecture.test.ts`), one commit with registration
+
+| edit | shape |
+|---|---|
+| `FORBIDDEN` | `lib: [..., "views"]`, `jobs: [..., "views"]`, new `controller: ["views"]`, new `views: ["components", "ytdlp", "bin", "social"]` — downward only |
+| `ROOTS` | append `"views"` (the line that makes it real) |
+| `ALLOWED` | delete the `StreamActionLog.tsx -> jobs/streamCommand` entry; `common/components/StreamActionLog.tsx:5` imports `StreamActionView` from `../views/streamAction` (it reads only `ok/error/info/jobId/stream`, `:140-147`, never `done`, so no `jobs/` type is needed) |
+| failure prose `:165-169` | add "views/ may not import components/" |
+| new `BARE_FORBIDDEN` | `{ views: /^(react\|react-dom\|next\|server-only\|client-only)(\/\|$)\|^node:\|^yt-dlp-transcript-common(\/\|$)/ }` — the self-name is listed because a package-name self-import hides from the relative check |
+| new `bareEdges()` | same walk + `IMPORT_RE`, roots in `BARE_FORBIDDEN`, **skip `*.test.ts`** (tests import `node:test`), collect `${rel} -> ${spec}` |
+| new test | `views/ imports nothing from react, next, node: or the package's own name` — guard-the-guard (views walk saw > 0 files) then `deepEqual([], bareEdges())` |
+| new test | `views/ never calls a singleton getter, a disk reader or the clock` — textual ban over non-test `views/` files, the `noCorpusWalk` trick: `getRegistry(`, `getScheduler(`, `getWorkerPool(`, `getPaths(`, `getSettings(`, `getAutoRunnerStatus(`, `readChannelStat(`, `readJobMeta(`, `listChannelBriefs(`, `listChannelConfigs(`, `readSchedulerState(`, `readAutoQueueState(`, `computeLeafPending(`, `readWorkerDefaults(`, `diskGate(`, `Date.now(` |
+| `common/package.json` | `"./views/*": "./views/*.ts"`; both test-glob brace lists gain `views` |
+| `noCorpusWalkInRenderPaths.test.ts:28` | `ROOTS = [EDITOR_APP, common/views]` for the walk test; the "cheap readers are referenced" test keeps scanning `editor/app` only (the readers are shell calls and stay there) |
+
+Consequence, stated: `views/` may value-import `controller/` and `jobs/` only for pure
+helpers (`digestCountOf`, `digestWorkOf`, `excludedDownloadIdSet`, `autoRunnerJobKind`,
+`laneDispatchRoot`, `buildScheduleView`, `isGateHeld`); every reader is injected. The
+textual ban is the cheap proof.
+
+### Per-builder refactor
+
+| builder | pure signature in `views/` | the shell passes | shell file (old path, old names, same values) | who repoints (D) |
+|---|---|---|---|---|
+| activeJobs | `buildActiveJobsPayload(i: ActiveJobsInputs)`, `liveJobRows(i: LiveJobRowsInputs, filter)`, `rowsFromJobsPage(page)`, `stuckJobIds(i)`. `ActiveJobsInputs = LiveInputs & { readChannelStat(slug); readJobMeta(id); tailLog(id); disk: DiskGateStatus; autoRunnerStatus(kind) }` — readers injected as functions because slugs are only known after `registry.list()`. `LiveJobRowsInputs` has no `disk`/`tailLog`/`autoRunnerStatus` | the `node:fs/promises` 8 KB tail (`:136-163`) moves INTO the shell as `readLastLogLine`; `diskGate(paths, settings, {mode:"observe"})`; `getAutoRunnerStatus` | `jobs/active/buildActiveJobs.ts` → ~45 lines; `listJobRows(paths, opts) = rowsFromJobsPage(await listAllJobs(paths, opts))`; all nine value consumers untouched | `DashboardCockpit`, `PipelineBand`, `LaneDeck`, `WidgetControls`, `MonitorWidget` (types) |
+| workers | `buildWorkersPayload(i: Pick<LiveInputs,"registry"\|"pool"\|"settings"> & { workerDefaults })`, sync | `readWorkerDefaults(paths)` | `workers/buildWorkers.ts` → 6 lines; `WorkersView.tsx` imports the three types from views and re-exports them until D | same five + `WorkersView` |
+| autoQueueLanes | `buildAutoQueueLanes({ settings, briefs, workerSummary })`, sync | folded into the status shell | `operations/lanes.ts` keeps `export type { LaneWorker }` until D, then deleted | `LaneHeader`, `RunnerOperationView` |
+| autoQueueStatus | `buildAutoQueueStatusPayload(Pick<LiveInputs,"settings"\|"pool"\|"now"> & { priority: PriorityView; runner; state: AutoQueueState; pending; lanes })`, sync | `readPriorityView()` (stays in editor), `readAutoQueueState(paths)` ONCE (was 4×, note in commit body), `computeLeafPending` ×4, `getAutoRunnerStatus` ×4, `getChannelBriefs` | `operations/status.ts` → ~40 lines keeping the name; route and pages untouched | nine `operations/components/*` type importers |
+| widgetSync | `buildWidgetSyncPayload({ settings, now, state: SchedulerState, briefs })`, sync | `readSchedulerState`, `getChannelBriefs`, `Date.now()` | **no shell**: new `widget/lib/syncInputs.ts` exports `widgetSyncInputs()`; the route becomes `GET = NextResponse.json(buildWidgetSyncPayload(await widgetSyncInputs()))` and exports only `GET`/`dynamic`; `app/page.tsx:20` → views + syncInputs | same five (types) |
+| schedulerStatus | `buildSchedulerStatusPayload({ settings, now, state, channels, heartbeatSeconds })`, sync | `readSchedulerState`, `listChannelConfigs`, `resolveHeartbeatSeconds()` (reads env + imports `runTick`; never from views) | `scheduler/status.ts` → ~25 lines | `operations/syncRow.ts`, `SyncConsole.tsx`, `operations/[id]/page.tsx:26` |
+| pulse | `computePulse(o: ObserveInputs): { rev; activeJobs; runningJobs; busy }` = `route.ts:63-122` with globals → `o.*`, `createHash` → `o.digest`, `mtime()` → two numbers; rev bytes unchanged | `observeInputs()` | **no shell**: the route keeps `GET` (idle fast path, then `cleanableTotalBytes` — out of scope, stays) | `components/pulse.ts:4` (type) |
+
+Two contracts to keep exactly: `liveJobRows` never heals (four server components call it
+with a filter, `buildActiveJobs.ts:222-235`); `buildActiveJobsPayload` always heals
+(`scheduler.complete` at `:341` stays in the view, asserted against `createScheduler()`).
+
+## Dependency graph
+
+```
+A registration + guard + inputs/liveInputs + streamAction burn + ALL pure renames ← serial, first
+ ├── B jobs group: views/workers.ts, views/activeJobs.ts (+ shells, purity tests)
+ └── C console group: schedulerStatus, autoQueueLanes+channelPriority+autoQueueStatus, widgetSync, pulse
+D repoint every TYPE import; delete the type re-exports and the empty operations/lanes.ts; record ← serial, on the merged tip
+```
+
+After A, **B and C share no file**:
+
+| file | owner |
+|---|---|
+| `common/package.json`, `architecture.test.ts`, `noCorpusWalk…`, `lib/liveInputs.ts`, `widget/page.tsx` | A only |
+| `app/page.tsx` (`:20`) | C only |
+| the five shared client files (`DashboardCockpit`, `PipelineBand`, `LaneDeck`, `WidgetControls`, `MonitorWidget`) | A, then D |
+| `operations/[id]/page.tsx`, the nine `operations/components/*` | D only |
+| `buildActiveJobs.ts`, `buildWorkers.ts`, `WorkersView.tsx` | B only |
+| `operations/{lanes,status,channelPriorityView}.ts`, `scheduler/status.ts`, `api/widget/sync/route.ts`, `api/pulse/route.ts`, `components/pulse.ts` | C only |
+
+## Slices
+
+### A — registration, guard, port, burn, pure renames (serial, first)
+
+- **A1** guard + registration + `views/inputs.ts` + `views/streamAction.ts` (entry
+ deleted, `StreamActionLog.tsx:5` repointed) + `lib/liveInputs.ts` + noCorpusWalk
+ widening. One commit: the stale-entry test forces the burn and the type move together.
+- **A2** `git mv` the pipeline five (+3 tests) → `views/pipeline/`; rewrite package-name
+ imports to relative; repoint `flow/*` (9), `PipelineStageCard`,
+ `channels/[slug]/page.tsx`, `videos/page.tsx`, `videoRowsServer.ts:6` (`server-only`,
+ stays), `StateBand.tsx`, `HoldSieve`.
+- **A3** `git mv` `laneState`, `jobRowView`, `jobRows`, `channelGroupSections`,
+ `builtInPresets` (+4 tests); repoint `LaneCard/LaneDeck/LaneRail`,
+ `JobsTable/JobProgressBars/LaneStrip/RunningJobsList/OperationDetail/MonitorWidget`,
+ `channels/{page,groupActions,components/*}`, `widget/{page,builder/page,PresetsRow}`,
+ `builtInPresets.test.ts` (stays), and `buildActiveJobs.ts:27-39`.
+
+### B — jobs group (parallel with C, off A's tip)
+
+- **B1** workers: `views/workers.ts` + `workers.test.ts`; `buildWorkers.ts` → shell;
+ `WorkersView.tsx` re-exports the three types.
+- **B2** activeJobs: `views/activeJobs.ts` + `activeJobs.test.ts`; `buildActiveJobs.ts` →
+ shell with `readLastLogLine`.
+
+### C — console group (parallel with B, off A's tip)
+
+- **C1** schedulerStatus. **C2** lanes + channelPriority + status (`readAutoQueueState`
+ once). **C3** widgetSync + `widget/lib/syncInputs.ts`; the route exports only
+ `GET`/`dynamic`; `app/page.tsx:20` repointed. **C4** pulse: `computePulse` + the
+ `observeInputs()` call in the route.
+
+### D — repoints and record (serial, on the merged tip)
+
+Repoint every TYPE import to `yt-dlp-transcript-common/views/*`; delete the type
+re-exports (`WorkersView.tsx`, `operations/lanes.ts`); numbers; `editor/CHANGELOG.md`; the
+Shipped record below; the full 533.
+
+## Invariants
+
+- Every payload type name, every wire field, `/api/pulse`'s `rev` bytes, `/api/widget/*`
+ paths: unchanged (FACTS:2697).
+- `views/` imports common relatively; no bare specifier but real third-party packages; no
+ `node:*`.
+- No `views` module value-imports a reader (`controller/channels`, `jobs/jobMeta`,
+ `jobs/listJobs`, `jobs/syncSchedulerState`, `jobs/workerDefaults`, `lib/diskSpace`,
+ autoRunner's readers) — types only.
+- Allow-list 11 → 10; nothing added. No rendering change. No route path change.
+- Untouched: `loadActionable`, `loadCleanup`, `heartbeat/runTick`, client `pulse.ts`, all
+ routes' paths, settings schema.
+
+## Tests to ADD
+
+Guard: the two new architecture tests; the widened corpus-walk test asserts the views root
+yielded > 0 files. Per builder, in `common/views/*.test.ts` with stub `{list,get}`
+registry, `createScheduler()`, stub pool, fixed `now`:
+
+- `activeJobs.test.ts`: `builtAt === now`; `tailLog` called only for stuck rows;
+ `readChannelStat` once per distinct slug; slot without record → `readJobMeta` → phantom
+ row; terminal record holding a slot → `scheduler.complete` called and the row drawn once;
+ `liveJobRows` never calls `complete`; stopped runner → `unavailable`.
+- `workers.test.ts`: tasks grouped by `workerId` for running transcribe tasks only;
+ `canStopPartial` per worker; `defaultEnabledIds` null vs list; `downloadsPaused` off
+ `isGateHeld`.
+- `pulse.test.ts`: all-null singletons → `activeJobs 0`, `busy false`, deterministic rev;
+ rev moves with `progress.current` and with `snapshotGeneration`; identical inputs →
+ identical rev.
+- `widgetSync.test.ts`: two briefs → sums; a snapshot lacking `eligible` → `null` for that
+ kind only; `kinds` sorted reachable-desc then id; `nextRunAt`/`overdue` off
+ `buildScheduleView`.
+- `autoQueueStatus.test.ts`: cooldowns filtered by `now` and sorted; `held` from
+ `pool.isPaused()` for transcription, `isGateHeld` otherwise; `policy.root` compiled vs
+ stored.
+- `autoQueueLanes.test.ts`: `worker#1/#2` fold to one row with `slots 2`; untagged remote
+ worker not listed; llm worker listed by tag.
+- `schedulerStatus.test.ts`: pass-throughs; `channels` is `buildScheduleView`'s output.
+
+Moved as-is: 62 tests in six files. Common 1159 → ~1,246; editor unit ~110 → ~48.
+
+## Verification
+
+Every commit: `pnpm -r exec tsc --noEmit`. Every sub-slice, from its worktree:
+`pnpm --filter yt-dlp-transcript-common test`;
+`pnpm -C editor exec tsx --test "app/**/*.test.ts"`;
+`pnpm --filter editor exec next build` (the only proof the client bundles resolve
+`views/{laneState,jobRows,pipeline/band,builtInPresets,channelGroupSections}` and nothing
+dragged `node:fs` into a `"use client"` graph); `pnpm --filter export exec next build` once
+on A.
+
+e2e (detached, queue lock, worktree, composed fixture site copied into `export/public`):
+
+| sub-slice | specs first |
+|---|---|
+| A | `channel-line`, `channel-stage-selection`, `channel-work`, `channel-groups`, `channels-counts`, `pipeline`, `jobs`, `jobs-active-order`, `jobs-channel`, `widget`, `dashboard` |
+| B | `jobs*`, `jobs-batch-tasks-drain`, `jobs-reorder`, `jobs-retry`, `workers`, `worker-remote`, `worker-unit`, `dashboard`, `dashboard-paths`, `widget` |
+| C | `auto-queue`, `lane-runner`, `operation-settings`, `scheduler`, `pulse`, `widget`, `dashboard` |
+| D / merged tip | the full 533 |
+
+## Traps for the implementer
+
+- Relative imports or the guard is blind; the self-name is in `BARE_FORBIDDEN` so a
+ forgotten rewrite fails loudly.
+- `ROOTS` is explicit; the guard-the-guard assertion is what catches a forgotten `views`.
+- Tests import `node:test` — the bare check skips `*.test.ts`; never loosen the regex to
+ get green.
+- Textual bans are context-blind: a comment saying "we do not call getRegistry() here"
+ fails the ban test; reword.
+- Exports map entry is a string, never an array; a `.tsx` under `views/` would need an
+ explicit line.
+- `React.cache` keys on `paths` identity: pass `liveInputs().paths` straight through, never
+ spread it.
+- `getAutoRunnerStatus` constructs: an extra supplied by shells that already construct;
+ `observeInputs()` never touches it.
+- `stageStatus.ts:13-16` is a type-only import of `channelMedia` (node:fs) imported by
+ client components — keep `import type`; only `next build` can see a violation.
+- One noun, one file: `deriveLaneState` is value-imported by the `"use client"`
+ `LaneDeck.tsx:11`; co-locating it with `activeJobs.ts` (which value-imports
+ `controller/autoRunner` → `node:path`) kills the client bundle.
+- `WorkersView.tsx` is `"use client"`: B moves the types out and leaves `export type`
+ until D; never let the view import from it "for now".
+- A route file ends up exporting only `GET`/`dynamic`; `widgetSyncInputs()` goes in
+ `widget/lib/syncInputs.ts`.
+- `git mv` then edit, add by path; never boot against `transcripts/`; `next build` never
+ in the primary checkout.
+- `builtInPresets.test.ts:4` imports `./config` — the test stays in the editor.
+
+## Numbers (from the inventory)
+
+Pure renames move 2,662 non-test lines; builders 1,423 lines out, ~250 back as shells; net
+`editor/app` shrink ≈ 3,800 lines plus 1,636 test lines. 20 files under `views/`.
+Allow-list 10.
+
+## Cadence
+
+Fable plans and reviews. A: one Opus implementer, worktree `one-core/phase-3-s1a`, lands on
+`main` first (three commits). B and C: two Opus implementers in parallel on
+`one-core/phase-3-s1b` / `-s1c` from A's tip. D: one implementer on the merged tip, then
+the full 533. Standing prompt rules: plan path, fish `commit -F` under `$CLAUDE_JOB_DIR/tmp`,
+the two trailers, add by path, never boot against `transcripts/`, e2e detached + Monitor
+(no polling), commit small, the report contract (shas, exact gate outputs, divergences,
+undone).
+
+## Record
+
+Filled in as sub-slices ship: sha range, actual gate numbers, every divergence from this
+plan.