commit 9cbed498b846120abc958645fef892d22b30f332
parent 70b97caa2789699c425922563771da20628f984d
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Fri, 25 Sep 2026 10:55:30 -0400
plans: release 7 — YouTube lane pacing + the archilyzer CLI, then one rollout (releases 6 + 7)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
| A | plans/release-7.md | | | 317 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
1 file changed, 317 insertions(+), 0 deletions(-)
diff --git a/plans/release-7.md b/plans/release-7.md
@@ -0,0 +1,317 @@
+# Release 7 — YouTube lane pacing + the archilyzer CLI, then ONE rollout (releases 6 + 7)
+
+Written 2026-09-25 morning on `main` `0032ed8a`. Verified by three Explore passes and one design
+pass; anchors are on `0032ed8a`. Operator decisions (2026-09-25): release 6 is NOT rolled out on its
+own; release 7 = two parallel slices + one rollout at the end that brings 6 + 7 live, editor restart
+AND umtool rebuild + restart; scope = pacing slice + CLI slice (Phase 4 slice 2 + the rest of spec
+item 1 + hub deploy path + the posts-only fix); Phase 4 slice 3 (config + docs, `doctor`, `run`,
+`mcp`) waits. Rules: `plans/tools/implementer-rules.md`. Record file: this file.
+
+## Context — where things stand
+
+- **Live on :3001:** `93dcb532` (releases 4 + 5), `BUILD_ID` `FKE60BTWpUiUa94WCSxTO`, next-server
+ 888107. umtool on :3050: pid 5881, `BUILD_ID` `JKtTfoVrUbTosNENO3GMj` (Sep 22 build).
+- **`main` = `0032ed8a`**: release 6 merged — follow-ups `4d97049f` (410 → deleted; umtool gets the
+ platform args through ONE table in `common/ytdlp/platformArgs.mjs`; atomic remote-transcript
+ write; measure-nav) and Phase 4 slice 1 `cda35622` (`buildDeployCore.ts` → `common/publish/build.ts`,
+ aws-sdk deps to common). Record `plans/release-6.md`.
+- **Rollout hazard already behind us:** `pnpm install` ran in the primary at 01:12 —
+ `editor/node_modules/@aws-sdk` pruned, `common/node_modules/@aws-sdk/{client-s3,lib-storage}`
+ present, `umtool/report-to-video/node_modules/yt-dlp-transcript-common → ../../../common` present.
+ The live build's `editor/.next/node_modules/@aws-sdk/client-s3-3c41832e78b52e1d` resolves into
+ `node_modules/.pnpm/` (safe while the old build serves). Install MUST precede the editor build.
+ umtool changed in release 6 (4 files) → this rollout rebuilds + restarts umtool too; its jobs
+ already spawn the new `build-video.mjs` from disk through the symlink.
+- **Why pacing:** `plans/youtube-lane-pacing.md` — the 2026-09-24 evening's three YouTube cooldowns
+ were ONE Short (`quarteringvlogs/ncdPaDSqt-c`) retried 12× from the head of the queue; with
+ `order: "listed"` the runner re-picks the same video after every cooldown and `fails` climbs to the
+ 30-min cap; a manual Sync reads the same cooldown and is refused. Every retained YouTube 429 is the
+ subtitle fetch, per video, not IP-wide. Also: `metadataScanStore.ts:250` never refreshes an
+ identical error's `at`, so 142 members-only `legal-mindset` videos are re-scanned (cookie-authed,
+ ~1 req/s) at EVERY runner start.
+- **Why the CLI now:** `build:hub` has no deploy path (the hub's `transcriptDownloads: false` is set
+ but no hub was rebuilt); `export/package.json:26` `deploy` runs wrangler with NO `--project-name`;
+ the `build`/`build:nodata` twins are identical bodies differing only by npm's `prebuild` hook;
+ `docker/{build-site,publish-site}.sh` call `pnpm run build:nodata|build` directly.
+- **Posts-only 404 root cause** (jeralyzer `thequartering-X`): a social channel (`sourceKind:
+ "social"`, `channelConfig.ts:25-28,116`) never enters the video scan so `count` stays 0;
+ `buildIndex.ts:1052-1113` still writes `transcripts/<slug>/manifest.json` with `pageCount: 0`;
+ `compose-site.ts:602-606` `reconcileChannelTree` computes `dirSignature(src, "manifest.json")`,
+ gets `""` for a manifest-only tree and `rm(dest)` — while `corpus.ts:248` advertises
+ `manifests.transcripts` unconditionally (and `llms.txt` at `corpus.ts:403`).
+- `archilyzer.pages.dev` answers 522 because the `homepage` package (Pages project `archilyzer`,
+ `homepage/package.json:18`) has NEVER been deployed; the hub has never been deployed either.
+ Operator decision 2026-09-25: keep them two apps on two projects (`archilyzer` = homepage,
+ `archilyzer-hub` = the hub), cross-linked; both get their first deploy in this rollout.
+- Not in this release: the LM chat-only tier; "hub absorbs homepage".
+
+## Slice Y — YouTube lane pacing (branch `one-core/r7-pacing`)
+
+**Decision: the per-video deferral is PERSISTED beside `platformBackoff`, not in-memory.** `fails`
+already survives a restart (persisted at `autoRunner.ts:1965`, kept by `pruneExpired` on boot :1154);
+an in-memory deferral would make every restart (rollouts restart the editor) re-hit the same video
+at `fails+1`. The status view already reads `inputs.state[kind]` (`common/views/autoQueueStatus.ts:140`),
+and e2e can seed it exactly like `queues.spec.ts:48-61` seeds `platformBackoff`.
+
+1. **Pure helpers, `common/jobs/platformBackoff.ts`** (append; `nextBackoff` :31-40 untouched):
+ `VideoDeferral = {until, channelSlug}`, `VideoDeferralState = Record<videoId, VideoDeferral>`,
+ `VIDEO_RATE_LIMIT_DEFER_MS = 6 h`, `deferVideo`, `isVideoDeferred`, `pruneDeferred`,
+ `coerceVideoDeferrals` (mirror `coercePlatformBackoff` :79). `platformBackoff.test.ts` +5.
+2. **State, `common/jobs/autoQueueState.ts`**: `AutoQueueKindState` :36-44 gains `videoDeferrals`;
+ `emptyAutoQueueKindState` :56, `coerceKindState` :108-112, the trim in `writeAutoQueueState`
+ :147-151. Old state without the key → `{}`; an older build ignores the key (rollback-safe).
+ `autoQueueState.test.ts` +2.
+3. **The one seam, new `common/jobs/unitOutcome.ts`**: `applyUnitOutcome(state, unit, now, rand?) →
+ {markCompleted, line}` — the body of `autoRunner.ts:1941-1963` moved and extended: `rate_limit` →
+ `nextBackoff` + `deferVideo` + line `Auto-download: <pf> rate_limit — backing off <s>s (attempt
+ <n>). <id> deferred 6h; next video after cooldown.`; `network` → backoff only (today's line);
+ `transcribed` → `clearBackoff`; every branch `pruneDeferred`. `autoRunner.ts:1941-1963` becomes
+ the call + `onLog(line)` + `markCompleted`; `persist()` :1965 unchanged. `unitOutcome.test.ts`
+ (~6 cases incl. `fails` climbing only across distinct ids). This is the testable seam the plan
+ file wanted (`runLoop` :1136 is not exported; `autoRunner.test.ts` covers only exports :6-14).
+4. **Pick filter + idle reason, `autoRunner.ts`**: boot prune :1154 adds `pruneDeferred`; the
+ download branch :1648-1655 also drops ids with `isVideoDeferred` and sets `anyDeferred`;
+ :1740-1741 → `anyCooling ? "cooldown" : anyDeferred ? "deferred" : "capped"`;
+ `AutoRunnerIdleReason` :208-210 gains `"deferred"`; `editor/app/operations/components/dispatch.ts:152`
+ gains the sentence `every pending video was rate-limited recently and is deferred` (exhaustive
+ switch, tsc forces it). Manual Sync / download-missing do not consult deferrals (say so in the
+ record; a manual retry still runs and on success clears the platform cooldown).
+5. **Scan-error `at` refresh, `metadataScanStore.ts:250`**: also rewrite when `Date.parse(now) -
+ Date.parse(prev.at) >= METADATA_SCAN_ERROR_COOLDOWN_MS` (`now` is the ISO arg :219; the
+ constant :334 is module-level). `metadataScanStore.test.ts` +2 (25 h later rewrites; 1 h leaves).
+6. **View, `common/views/autoQueueStatus.ts`**: `VideoDeferralView {videoId, channelSlug, untilMs}`;
+ `AutoQueueKindStatus` :60 gains `deferred: VideoDeferralView[]`; `buildKind` :140-145 fills it.
+7. **Strip, `RunnerOperationView.tsx:196-225`**: render when cooldowns OR deferred; `CooldownStrip`
+ becomes `<section role="region" aria-label="Rate-limit cooldown">` with the existing heading,
+ `<ul aria-label="Platforms in cooldown">` (items unchanged), and when deferred: heading
+ "Deferred videos — skipped by auto-download until:" + `<ul aria-label="Deferred videos">` with
+ `<li>` `alpha/a1 — 5h 59m left` (link like `RecentPicks` :167-169). `formatCooldown`
+ (`dispatch.ts:202-207`) gains an hours arm (grep callers first). **Accessible names, exact:**
+ region `Rate-limit cooldown`; lists `Platforms in cooldown`, `Deferred videos`.
+8. **e2e, new `editor/e2e/pacing.spec.ts`** (copy `makeDownloadChannel` :110-149, `getStatus`/
+ `pickOrder` :185-198, the settings block :772-779 from `auto-queue.spec.ts`):
+ - T1 seeded: alpha `["a1","a2"]`; `state.json` `download.videoDeferrals = {a1: {until: now+1h,
+ channelSlug: "alpha"}}`; start the runner; picks → exactly `["a2"]`; `idleReason === "deferred"`;
+ `/operations/download` has `getByRole("region", {name: "Rate-limit cooldown"})` and
+ `getByRole("list", {name: "Deferred videos"})` containing `alpha/a1`.
+ - T2 live 429: **new fake sentinel `dl429`** in `fake-ytdlp.mjs` `modeYoutubeSingleUrlManaged`
+ (:434-446): stderr `ERROR: [youtube] <url>: Unable to download video subtitles for 'en': HTTP
+ Error 429: Too Many Requests`, exit 1 (`availability.ts:246` → `rate_limit`); the prefetch branch
+ (:770-795, `urlSentinels` :143-157) still succeeds — the real two-spawn shape. alpha
+ `["dl429vid1","a2"]`; poll `state.json` for `videoDeferrals.dl429vid1` + `platformBackoff.youtube
+ .fails === 1`; then poll picks until `a2` (timeout 100 s; base cooldown 60 s ±10 %);
+ `pickOrder` = `["dl429vid1","a2"]`. `test.setTimeout(150_000)`.
+ - Keep green: `fetch-window queues rumble-sweep auto-queue lane-runner` (idleReason readers
+ `auto-queue.spec.ts:1105`, `lane-runner.spec.ts:247,518`).
+9. **`sleepBetweenDownloadsSeconds` in the lane: OUT.** The failing attempts were already 93 s to
+ 30 min apart; it would prevent none of the observed 429s and adds a second timer every fixture
+ must neutralise. Ship the deferral alone so the first evening's log answers one question.
+10. Numbers: none (`state.json` is outside both numbers tools); say so. `videoDeferrals: {}` appears
+ on all four lanes at the first persist after boot — the expected change outside the md5 baseline.
+
+Y owns: `common/jobs/{platformBackoff,autoQueueState,unitOutcome}.ts` (+tests),
+`common/controller/{autoRunner,metadataScanStore}.ts` (+test), `common/views/autoQueueStatus.ts`,
+`editor/app/operations/components/{RunnerOperationView.tsx,dispatch.ts}`,
+`editor/e2e/fixtures/bin/fake-ytdlp.mjs`, `editor/e2e/pacing.spec.ts`. Gates: rules' list; e2e
+`pacing fetch-window queues rumble-sweep auto-queue lane-runner`; numbers "none".
+
+## Slice C — the archilyzer CLI (branch `one-core/r7-cli`)
+
+1. **Parser: hand-rolled** (no `commander`/`yargs` in the workspace; `minimist` only transitively).
+ `common/bin/_parseFlags.ts` gains `parseArgv(argv) → {flags, positionals}` beside `parseFlags` :4.
+ New `common/bin/_cli.ts`: `Command = {path: string[], usage, run({positionals, flags, env}) →
+ Promise<number>}`, `resolveCommand` (longest-path match), `usage()`. `common/bin/archilyzer.ts` =
+ the table with lazy `await import("./compose-site")` per subcommand. `common/package.json` `test`
+ glob gains `bin`. `_cli.test.ts`.
+2. **Bins become import-and-call.** `build-index`, `build-stats`, `build-chart-templates`,
+ `build-archives`, `compose-site`, `compose-hub`, `compose-homepage`, `sync-tick`,
+ `settings-example`, `file-schemas-docs` each `export async function main(opts)` and guard the
+ auto-run with the `scripts/worktree.mjs:350` idiom (`import.meta.url === pathToFileURL(
+ process.argv[1]).href`) so `tsx bin/x.ts` keeps working (`fileSchemaDocs.test.ts:25` names
+ `bin/file-schemas-docs.ts`). Env → argument where a subcommand names it: `compose site <id>`
+ passes `siteId` (`compose-site.ts:634` reads `SITE_ID` today; error if neither). Flag-driven bins
+ (`_parseFlags` users) are untouched — `run <operation>` material for slice 3.
+3. **Named entry points, `common/publish/build.ts`** (thin, over the existing `run*`; keep every
+ `run*` export, `build.test.ts` pins three): `buildSite(siteId, {paths, onLog, signal, skipData,
+ skipArchives})`, `deploySite(siteId, {…, previewBranch})` (= `getSite` + `builtSiteProblem` +
+ `runArchiveUploadIntoLog` + `runDeployIntoLog`, the body of `deployAction.ts:24-80` minus the
+ pre-job refusals, which stay in the action), `buildAll({…, mode: "docker"|"basic", skipArchives})`
+ (docker → `runDockerBuildAllPhase` :456; basic → the serial loop lifted from
+ `buildAction.ts:328-360`), `composeHub`, `buildHub`, `deployHub`, `composeHomepage`. Editor
+ actions keep `runManagedFunction` (`streamCommand.ts:289`) and call these.
+4. **Twins collapse + `prebuild`.** `runBuildPhase` :52-88 stops spawning `pnpm run build|build:nodata`
+ and runs the steps itself: `!skipData` → `runHostScript(…, "build:data")` (already used :456),
+ then `pnpm run compose:site` (env block :80-87), then `pnpm exec next build`, all in `export/`,
+ same `[notice]` lines. Extract `buildSiteSteps(opts) → [{command, args, env}]`, pin it in
+ `build.test.ts`. `export/package.json`: `build` → `tsx ../common/bin/archilyzer.ts build site`
+ (`SITE_ID` from env; `pnpm run build -- --nodata` forwards), **delete `build:nodata` :18 and
+ `prebuild` :16** (pnpm runs pre-scripts by default, no `.npmrc` override — `build.ts:59-63`), `build:hub`
+ :19 → `… build hub`, `deploy` :26 → `… deploy site` (refuses without `SITE_ID`/project). Root
+ `package.json`: `build:index` :8 → `archilyzer index`, `sync:tick` :9 → `archilyzer sync tick`;
+ `build`/`build:export` stay aliases. `homepage/package.json:11-13` twins stay (different app).
+5. **Docker.** `docker/build-site.sh:40` → `pnpm --filter yt-dlp-transcript-common exec tsx
+ bin/archilyzer.ts build site "$SITE_ID" --nodata`; `publish-site.sh:38` → `… build site "$SITE_ID"`.
+ `Dockerfile.build` copies root/common/export `package.json` (tsx is in common devDeps :28);
+ ENTRYPOINT :44 unchanged. `Dockerfile:246-253` copies every package.json → the editor image has
+ the CLI.
+6. **Hub build + deploy.** The hub is `export` under `INSTANCE_MODE=hub` (`export/app/lib/site.ts:29-44`),
+ out = `export/out` (shared with site builds, `resolveOutDir` :29). `buildHub` = `pnpm run
+ compose:hub` then `INSTANCE_MODE=hub pnpm exec next build` in `export/`; before composing
+ `rm -f public/site.json`, and symmetrically compose-site removes `public/hub-sites.json` (compose-hub
+ touches only `hub-sites.json`, `compose-hub.ts:61-62`) so `out/` says what it is.
+ `common/lib/builtExport.ts` gains `builtHubProblem(outDir)`. `deployHub` reads
+ `getHomepageConfig(paths).cloudflareProject` — **the key, form field and writer already exist**
+ (`common/lib/homepage.ts:33-34,83-86,144-147`, `HomepageConfigForm.tsx:56-62`,
+ `homepageActions.ts:33-43`); refusal `The hub has no Cloudflare Pages project configured — set it
+ on /sites under Hub.`; then `pagesDeployArgs({outDir, project, previewBranch})` (`pagesDeploy.ts:65`)
+ via `runChildIntoLog`, mirroring `runDeployIntoLog` :267-300 incl. `deploymentUrlIn`. Editor:
+ `editor/app/sites/lib/hubActions.ts` (`"use server"`): `buildHubAction`, `deployHubAction`,
+ `buildAndDeployHubAction` → `runManagedFunction` kinds `build-hub` / `deploy-hub` /
+ `build-deploy-hub`, queueKeys `"build"` / `"deploy"` (`buildAction.ts:38-39`). UI:
+ `HubBuildButtons.tsx` (copy `BuildAllSitesButton.tsx:18-70` + `JobLane`) in the Hub section
+ `editor/app/sites/page.tsx:181-197`. **Names, exact:** buttons `Build hub`, `Deploy hub`, checkbox
+ `Deploy after build`; `JobLane` titles `Build hub` / `Deploy hub` / `Build & deploy hub`. Ops
+ adapters: `editor/app/api/ops/{build-hub,deploy-hub}/route.ts`, `scripts/archilyzer-ops.mjs:95-100`
+ ACTIONS + its test, `ops-api.spec.ts` +1 (deploy-hub refuses when the fixture `homepage.json` has
+ no project). CLI: `build hub`, `deploy hub [--preview <b>]`.
+ **Hub Pages project: `archilyzer-hub`** (operator decision 2026-09-25: two projects, cross-linked;
+ the `homepage` package keeps `archilyzer` = https://archilyzer.pages.dev, never deployed yet).
+ Rollout sets on `/sites` → Hub: Cloudflare project `archilyzer-hub` (today the file says
+ `archilyzer` — the collision the design found) and `siteUrl` `https://archilyzer-hub.pages.dev`
+ (`compose-hub.ts:69-86` needs it for the hub's `corpus.json`/`llms.txt`/`robots.txt`).
+ **Cross-links (C owns):** the homepage hero (`homepage/app/page.tsx:79-85`, beside "Download the
+ source" / "Read the setup guide") gets a "Search all archives" button to `homepage.json.siteUrl`
+ (the hub's URL — the same field `hubSite()` reads; rendered only when set); the hub's footer
+ "Built with Archilyzer" (`export/app/components/Footer.tsx:12,85`) already points at `PROJECT_URL`.
+ **Docs:** `mcp/README.md:457,508` hub examples → `https://archilyzer-hub.pages.dev`; the hub-URL
+ hints in `SETTINGS.md:486` / `settingsSchema.ts:1518` / `HomepageConfigForm.tsx:52` / `SiteForm.tsx:251`
+ are checked and repointed if they name archilyzer.pages.dev as a hub. `README.md:13,474` and
+ `SETUP.md:8,175` keep pointing at the homepage (docs + tarball) — unchanged. CLI also gains
+ `build homepage` / `deploy homepage` (thin: `composeHomepage` + `next build` in `homepage/`;
+ deploy = `pagesDeployArgs` with the constant project `archilyzer`, replacing the hardcoded
+ `homepage/package.json:18` line) so the rollout deploys the homepage headlessly too.
+7. **Posts-only 404 — fix the composer, keep the contract.** `compose-site.ts:602-606`: when the
+ signature is `""` but `src/manifest.json` exists, use a constant signature and copy the tree; a
+ `pageCount: 0` manifest is exactly what "0 transcripts" means; `corpus.ts:248` stays
+ unconditional, spec stays 4, readers untouched. New `common/bin/compose-site.test.ts` (export
+ `reconcileChannelTree`; possible once step 2 guards `main()` :916): manifest-only tree copied,
+ unchanged tree skipped, no manifest → removed. No export e2e can pin it (no spec reads
+ `corpus.json`; fixture site has only `test-youtube`) — say so; `editor/e2e/build.spec.ts:10`
+ pins the 0-transcript manifest on the index side.
+8. **Cut line.** Ships: `index`, `compose site <id>|hub|homepage`, `build site <id> [--nodata]
+ [--skip-archives] | all [--skip-archives] | hub`, `deploy site <id> [--preview b] | hub [--preview b]`,
+ `sync tick`, `settings example [--check]`. **Deferred to slice 3:** `doctor` (inputs
+ `paths.ts:232-264`, `umtool/lib/tools.mjs probeTools` + `doctor.ts`, `scripts/worktree.mjs:16-28
+ PORT_BASES`), `run <operation>`, `mcp`, "port defaults exist once".
+9. **Gates (C)**: tsc; common tests (+`_cli`, `compose-site`, `build`); editor unit 72; `pnpm run
+ test:scripts` (159 + 1 → +N); `pnpm --filter editor exec next build`; `pnpm --filter export exec
+ next build` (NOT `run build`); export e2e in full (192) + `e2e:hub` (8); editor e2e `sites-crud
+ site-publish-preview build deploy-page cut-release channel-build-toggle ops-api`;
+ `phase3-files-numbers.ts` diff-empty.
+
+C owns: `common/bin/**`, `common/package.json`, `common/publish/build.ts` (+test),
+`common/lib/builtExport.ts`, `export/package.json`, root `package.json`, `homepage/package.json`,
+`homepage/app/page.tsx` (hero button) + its e2e `homepage/e2e/marketing.spec.ts`,
+`docker/{build-site,publish-site}.sh`, `editor/app/sites/**`,
+`editor/app/api/ops/{build-hub,deploy-hub}/`, `scripts/archilyzer-ops.{mjs,test.mjs}`,
+`editor/e2e/ops-api.spec.ts`, `mcp/README.md` (hub URL examples), the hub-URL hint strings named
+above. Gates add the homepage e2e (`pnpm --filter homepage run e2e`, 5 specs — check the script name).
+
+**Overlap Y ↔ C: none.** Shared only `editor/CHANGELOG.md`, `plans/release-7.md`. Merge order Y → C
+(C merges `main` and re-gates). FACTS/STATE: parent, after both merge. Worktrees: `pnpm wt add
+one-core/r7-pacing` and `one-core/r7-cli` off `main`; symlink `export/public` per path; adding
+worktrees shifts port blocks. Reviews: Opus, read-only, `SHIP | SHIP AFTER FIXES | BLOCK`.
+
+## Rollout at the end (releases 6 + 7 together; ONE editor restart + umtool restart)
+
+Release-5 procedure (`plans/release-5.md` "## Rollout 2026-09-24 (night)") plus:
+1. Final suites on the merge sha in a detached worktree: editor full, export full, `e2e:hub`.
+2. Primary: `pnpm install --frozen-lockfile` (expected no-op; verify the three node_modules facts
+ above). MUST precede the editor build.
+3. Numbers on the live corpus: settings (1,353 paths, diff empty), files (77 `unknown keys: []`,
+ 3,839 lines) — no key changed in 6 or 7 (`cloudflareProject` on `homepage.json` predates both).
+4. md5 baseline: `settings.json` + 6 `site.json` + 71 `config.json` + `homepage.json` (79);
+ `jq '.download|keys' .auto-queue/state.json` before (no `videoDeferrals`).
+5. Detached builds: editor into the live `.next` (`FKE60BTWpUiUa94WCSxTO` → new; then
+ `readlink -f editor/.next/node_modules/@aws-sdk/client-s3-*` resolves into `node_modules/.pnpm/`);
+ umtool (cwd `umtool/`, `JKtTfoVrUbTosNENO3GMj` → new).
+6. ONE editor restart (TERM pnpm + next-server cwd `editor/`, `setsid nohup pnpm run start -H
+ 0.0.0.0`, `/` + `/tags` 200, no `ZodError`), then umtool restart (TERM 5881, its `start` on :3050,
+ `/` 200).
+7. md5 sweep after boot: identical. `.download.videoDeferrals == {}` after the first unit.
+8. Smoke = release-4 script (eight `/api/view/*` pairs, 404s, presets, `?rev=`, pages) plus:
+ `autoQueueStatus` `.download.deferred == []`; `/operations/download` 200 with NO `Rate-limit
+ cooldown` region while nothing cools; `/sites` shows `Build hub` + `Deploy hub`; umtool `/` 200;
+ **one site Build** `pnpm ops build-site {"siteId":"anilyzer","skipData":true,"skipArchives":true}
+ --wait` exit 0 (proves the SDK resolves from common AND the reshaped build: compose + `next
+ build`, no data phase, no `prebuild`).
+9. Hub + homepage, first deploys ever. On `/sites` → Hub set Cloudflare project `archilyzer-hub`
+ and `siteUrl` `https://archilyzer-hub.pages.dev` (through the form; md5 of `homepage.json` moves
+ once, expected). `pnpm ops build-hub --wait` (first ever hub build: `export/out/hub-sites.json`
+ with five members, `corpus.json`), `pnpm ops deploy-hub --wait`;
+ `curl https://archilyzer-hub.pages.dev/{hub-sites.json,corpus.json}` 200, federated search
+ returns a hit from two different sites, the transcript modal has no export controls. Then the
+ homepage: `archilyzer build homepage` + `deploy homepage` (project `archilyzer`) →
+ `https://archilyzer.pages.dev/` 200 (no longer 522), `/docs/install/` 200,
+ `/downloads/archilyzer-source.tar.gz` 200, hero shows "Search all archives" → the hub.
+ The MCP quickstart `hub:https://archilyzer-hub.pages.dev#jeralyzer,…` then works — try one
+ `list_sources` through it.
+10. Owed sync + md5: one `pnpm ops sync {"slug": <small youtube channel>} --wait`; only that
+ `config.json`'s stamps move.
+11. 410 re-read: `rekietalaw-rumble/v7e07us` and the four quartering ids read `deleted` at their
+ next check (observe, do not force).
+12. Pacing is live — a 429 cannot be provoked; proof = (i) the region + `deferred: []`, (ii) the
+ first real 429's line `… (attempt 1). <id> deferred 6h; next video after cooldown.`, (iii) the
+ next evening: `grep -h 'deferred 6h' transcripts/.jobs/*.log` — each id at most once per 6 h,
+ `attempt N` climbing only across distinct ids, the owed sync never refused for a single-video
+ cooldown. Scan-error refresh: the first runner start still bursts `legal-mindset`'s 142 cookie
+ requests ONCE; a second restart within 24 h logs no re-scan.
+13. Record this file's "## Rollout", STATE head, FACTS section, memory; morning runbook
+ (`~/reports/release-7/make-runbook.py` → `RUNBOOK.html`, the release-5 generator as the model).
+
+## Verification (end to end)
+
+Per slice: the rules' gate list + the spec lists above; Y numbers "none", C `phase3-files-numbers`
+diff-empty. Post-merge: step 1 suites; rollout steps 2-12 each with a recorded number. Y's live
+proof is the evening watch (12); C's is the site build (8) and the hub (9).
+
+## Hazards
+
+- **Labels are contracts.** New: region `Rate-limit cooldown`, lists `Platforms in cooldown` /
+ `Deferred videos`, buttons `Build hub` / `Deploy hub`, lane titles above. Unchanged and asserted
+ elsewhere: `Sync notice` + the `rate-limit cooldown` sentence (`queues.spec.ts:66`,
+ `rumble-sweep.spec.ts:160`), heading `Build all sites` (`deploy-page.spec.ts:22,38`), `idleReason`
+ values (`auto-queue.spec.ts:1105`, `lane-runner.spec.ts:247,518`).
+- **Runner state across restart**: deferrals persist by design; manual Sync/download-missing ignore
+ them; an older build drops the key on its next write (rollback-safe).
+- **`Dockerfile.build`**: `runDockerBuildOne` mounts the host `build-site.sh` (:437-439) but the CLI
+ and the new `export/package.json` are BAKED — an old image running the new script fails (old
+ `build` still has `prebuild`). Check `ensureBuildImage`'s rebuild trigger; the first docker-mode
+ build after C may need one `docker build -f Dockerfile.build`. Live rollouts use host mode.
+- **`export/package.json` `deploy` had no project name** — the CLI refuses without one. The hub
+ is `archilyzer-hub`; the homepage stays `archilyzer` — `homepage.json` currently says
+ `cloudflareProject: "archilyzer"` and MUST be changed on the form before `deploy hub`, or the
+ hub overwrites the project page. `deployHub` refuses the literal value `archilyzer` as a guard.
+- **Hub vs homepage stay two apps** (decision 2026-09-25). The homepage owns the software's public
+ paths (`/docs/install/`, `/downloads/archilyzer-source.tar.gz`, linked from README/SETUP/AGENTS)
+ and `PROJECT_URL` is stamped into every footer and corpus generator string; the hub is one
+ operator's federation. "Hub absorbs homepage" is a possible later slice (~30 files, 5 e2e, the
+ docker `homepage` service), not this release.
+- **`prebuild` semantics**: the collapse works only because the data phase becomes an explicit step
+ in `buildSite` and `prebuild` is deleted; `--nodata` still assumes a prior full build's staging.
+- **Shared `export/out`**: a hub build and a site build overwrite each other; `builtHubProblem` /
+ `builtSiteProblem` refuse the wrong deploy — never rebuild silently.
+- **e2e timing**: T2 waits a real 60 s ±10 % cooldown; expect ~2 min behind the machine-global
+ queue. `formatCooldown` needs the hours arm or a 6 h deferral reads `359m 58s`.
+- Session budget (memory `session-limit-pacing`): Y and C in parallel = the two heavyweight jobs of
+ one window; reviews and the rollout in the next.
+
+## Then
+
+Phase 4 slice 3 (config + docs + `doctor`/`run`/`mcp`, `PUBLISH.md` absorbing `DEPLOY_DOCKER.md` +
+`DEPLOY_CLOUDFLARE.md`); the-quartering-rumble retry-cadence stamp only if live sweeps come back
+incomplete; the LM chat-only tier (operator config).
+
+## Record
+
+## Rollout