Archilyzer · Source

archilyzer

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

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:
Aplans/one-core-phase-3.md | 327+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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.