# 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//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`, `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: rate_limit — backing off s (attempt ). 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 `
` with the existing heading, `
    ` (items unchanged), and when deferred: heading "Deferred videos — skipped by auto-download until:" + `
      ` with `
    • ` `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] : 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}`, `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 ` passes `siteId` (`compose-site.ts:634` reads `SITE_ID` today; error if neither). Flag-driven bins (`_parseFlags` users) are untouched — `run ` 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 ]`. **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 |hub|homepage`, `build site [--nodata] [--skip-archives] | all [--skip-archives] | hub`, `deploy site [--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 `, `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`, and `TWO_ORIGIN_REBUILD=1 node scripts/worktree.mjs run -- pnpm --filter export run e2e:2origin` (its globalSetup runs `pnpm run build:hub`, which slice C made `archilyzer build 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": } --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). 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 ### Slice Y, as shipped — YouTube lane pacing (2026-09-25) Branch `one-core/r7-pacing` off `main` `6ee1d336`. `main` did not move during the slice. Every spec anchor was checked on `6ee1d336` before its edit, and all of them held at `0032ed8a` line numbers. Items 1 to 8 were built as written. Item 9 (`sleepBetweenDownloadsSeconds`) stays out. Item 10: no numbers were taken. The slice fixes two things. First, a rate-limited video is **deferred for 6 h** as well as backing its platform off, so when the cooldown lapses the runner picks the next video instead of re-picking the same one. The deferral is persisted beside `platformBackoff` so a restart honours it. Second, an identical metadata-scan error now refreshes its `at` once it is a cooldown old, so members-only ids stop being re-scanned at every runner start. | sha | what | |---|---| | `65dca073` | items 1 + 2. `platformBackoff.ts` appends `VideoDeferral {until, channelSlug}`, `VideoDeferralState`, `VIDEO_RATE_LIMIT_DEFER_MS` (6 h), `deferVideo`, `isVideoDeferred`, `pruneDeferred` (drops `until <= now`; there is no retention, since a deferral has no escalation memory) and `coerceVideoDeferrals` (mirrors `coercePlatformBackoff`). `nextBackoff` is untouched. `AutoQueueKindState.videoDeferrals` is added to the empty state, to `coerceKindState` (a missing or corrupt key becomes `{}`) and to the write trim. `platformBackoff.test` +5, `autoQueueState.test` +2; the existing "lane coerces to empty" deepEqual gained the key | | `71c755c8` | item 3. New `common/jobs/unitOutcome.ts` `applyUnitOutcome(state, unit, now, rand?) → {markCompleted, line}`. `rate_limit` runs `nextBackoff` + `deferVideo` and logs `Auto-download: rate_limit — backing off s (attempt ). deferred 6h; next video after cooldown.` `network` backs off with today's line and no deferral. `transcribed` calls `clearBackoff`. A unit with no platform (a non-download lane) touches neither map, as before. Every branch prunes. The block at `autoRunner.ts:1941-1963` is now the call + `onLog(line)` + `markCompleted` (still skipped on an operation lane), and `persist()` is unchanged. `unitOutcome.test.ts` has 7 cases, including "`fails` climbs only across distinct ids" | | `a9af6a0a` | item 4. The boot prune adds `pruneDeferred`. After the platform gate, the download branch drops pending ids with a live deferral and sets `anyDeferred` only when it actually dropped one. The idle reason is `anyCooling ? "cooldown" : anyDeferred ? "deferred" : "capped"`. `AutoRunnerIdleReason` gains `"deferred"`. `dispatch.ts` `idleReasonText` gains "every pending video was rate-limited recently and is deferred" | | `cb041841` | item 5. `metadataScanStore.ts` `upsertMetadataScan` also rewrites an identical error when `Date.parse(now) - Date.parse(prev.at) >= METADATA_SCAN_ERROR_COOLDOWN_MS`. The flush passes `new Date().toISOString()` as `now` (`ytdlp/metadataScan.ts:374-379`). `metadataScanStore.test` +2: 25 h later rewrites `at` and `updatedAt`; 1 h later leaves the file's mtime alone | | `04f5c9be` | items 6 + 7. `autoQueueStatus.ts` adds `VideoDeferralView {videoId, channelSlug, untilMs}` and `AutoQueueKindStatus.deferred` (live deferrals only, soonest first). `RunnerOperationView.tsx` draws the strip when there are cooldowns OR deferrals. The strip is `role="region"` `aria-label="Rate-limit cooldown"`. Under the existing heading is `
        ` (items unchanged, drawn only when a platform cools). Then, when there are deferrals, the heading "Deferred videos — skipped by auto-download until:" and `
          `, whose items read `alpha/a1 — 5h 59m left` with the id a `Link` to the video page, as `NextUp` does. **The region is a `
          `, not the `
          ` the spec wrote**: the strip sits inside the lane's own `
          `, and the file's structural contract (`:30-32`) forbids a nested one because every `locator("section", {has})` in the suite would match two ancestors. The accessible role and name are the same. `formatCooldown` gains an hours arm (`5h 59m`, or `6h` when the minutes are zero). Its only caller is this strip, and platform cooldowns cap at ~33 min, so their text does not change | | `17dc70c0` | item 8. The fake's `dl429` sentinel is in `modeYoutubeSingleUrlManaged` only: stderr `ERROR: [youtube] : Unable to download video subtitles for 'en': HTTP Error 429: Too Many Requests`, exit 1. The prefetch branch still succeeds, which is the real two-spawn shape. New `editor/e2e/pacing.spec.ts`. T1 seeds `download.videoDeferrals.a1` and checks: picks `["a2"]`, idle `deferred`, `deferred == [a1]`, and the region + `Deferred videos` list containing `alpha/a1` with no `Platforms in cooldown` list. T2 checks: `state.json` shows `videoDeferrals.dl429vid1` (slug `alpha`, > 5 h left) and `platformBackoff.youtube.fails === 1`; then, after the real ~60 s cooldown, picks become `["dl429vid1","a2"]` (`setTimeout(150_000)`) | | `ffe71f42` | e2e fix. T1's idle-sentence assertion hit two elements (the rail and the lane both draw the sentence, a strict-mode violation), so it now asserts `.first()` | | `5be6ceb5` | this record, `[Unreleased]` bullets, and the correction note at the top of `plans/youtube-lane-pacing.md` | | `8094615d` | (review fix) `common/views/activeJobs.ts` `autoIdleNote` gains `case "deferred"` with `dispatch.ts`'s exact sentence. It is now typed `AutoRunnerIdleReason \| null` with no `default` (`"stopped"` and `null` return null), so a new idle reason fails to compile here as it does in `idleReasonText`. `activeJobs.test` +1: a download runner idling `deferred` shows that sentence as the `Auto-download` lane's note | | `74c24b1f` | (review fix) `autoQueueStatus.test` +1 for `deferred`. Lapsed and boundary (`until === NOW`) entries drop. Out-of-order input comes out soonest first, and entries tied on `until` are ordered by `videoId`. The shape is `{videoId, channelSlug, untilMs}`, transcription gets `[]`, and a later injected clock drops the tied pair. The builder's tie-break changed from `localeCompare` to code-point order, so the strip's order does not depend on the server locale | | *(this commit)* | record: the two review-fix rows and the re-gate | **Gates** (worktree root, on `ffe71f42`). tsc (`pnpm -r --no-bail --workspace-concurrency=1 exec tsc --noEmit`) was clean before every commit. common **1770/1770** = 1754 + 5 (`platformBackoff`) + 2 (`autoQueueState`) + 7 (`unitOutcome`) + 2 (`metadataScanStore`). Editor unit **72/72**. test:scripts **159 pass + 1 skip**. mcp **219/219**. `pnpm --filter editor exec next build` ok (compiled in 27.6 s). `pnpm --filter export exec next build` ok (10.5 s; no dangling links under `export/public`). EDITOR e2e, `$T/y-specs.txt` = `pacing.spec fetch-window.spec queues.spec rumble-sweep.spec auto-queue.spec lane-runner.spec` (all six exist): - run 0 (`y-e2e0-misscoped.log`): **aborted at test 6 of 642**. The brief's bare names (`pacing …`) are Playwright path regexes, and `pacing` matches the worktree path `one-core-r7-pacing/`, so every spec was selected. Killed along with its orphan servers on :3711/:3710 and its ollama stub; the list was rewritten with `.spec` suffixes. **A worktree whose path contains a spec's name needs the suffixed form.** - run 1 (`y-e2e1.log`): **44 passed, 1 failed, 3.9 min**. T1 failed on its last assertion, the strict-mode double match fixed in `ffe71f42`. T2 passed (1.1 min). - run 2 (`y-e2e2.log`, `pacing.spec` alone): **2 passed, 0 failed, 1.4 min**. - run 3 (`y-e2e3.log`, the full list on `ffe71f42`): **45 passed, 0 failed, 4.3 min**. No run waited in the queue. **Numbers: none.** `.auto-queue/state.json` is outside both numbers tools (settings: 1,353 paths; files: config/site/sidecars), and no `settings.json`, `site.json` or `config.json` key changed. **`videoDeferrals: {}` will appear on all four lanes of `state.json` at the first persist after the rollout boot.** That is the expected change outside the md5 baseline (rollout step 4 records `.download|keys` before). An older build drops the key on its next write, so rollback is safe. **Re-gate after the review fixes** (tip `74c24b1f`). `editor/.next/dev` was removed first: e2e run 3 had left a truncated generated `validator.ts` there, and it was the only file tsc failed on. tsc clean. common **1772/1772** (1770 + 1 `activeJobs` + 1 `autoQueueStatus`). Editor unit **72/72**. EDITOR e2e `pacing.spec` alone (`y-e2e4.log`): **2 passed, 0 failed, 1.8 min**. The fixes change no runner behaviour, only the `/jobs` note, a test, and a same-`until` tie-break, so the other five specs were not rerun. test:scripts, mcp and the builds were not rerun either, because the fixes touch no file they cover beyond `common/views`, which tsc checks. **Found and left.** - **Manual Sync and *download missing* do not consult deferrals**, by design. A manual retry of a deferred video still runs, and if it succeeds it clears the platform cooldown. It does not clear the video's deferral: the runner retires a fetched id anyway, because it leaves `undownloadedIds` at the next snapshot. A manual 429 goes through `recordDownloadBackoff` (a platform cooldown only, no deferral). That read-modify-write now carries `videoDeferrals` through, since `readAutoQueueState` coerces it. - **The runner does not merge deferrals from disk mid-run** the way it merges `platformBackoff`. Only the runner writes deferrals, so there is nothing outside it to merge. A deferral hand-seeded into `state.json` takes effect at the next runner start, which is what T1 does. - *(Fixed on review, `8094615d` / `74c24b1f`:)* `activeJobs.ts` `autoIdleNote` had no `deferred` case, and `autoQueueStatus.test.ts` had no `deferred` case. Ownership of both files was extended to Y for these fixes. - The plan file's step 3 (a `plans/FACTS.md` entry: the lane defers a rate-limited video; the cooldown escalates across distinct videos; YouTube 429s are per-video timedtext) is the parent's to write. - **Commit trailers** name `Claude Opus 5.5 (1M context)`, as the release-6 implementers did. `implementer-rules.md` still names Fable 5.1. ### Slice C, as shipped — the archilyzer CLI, hub deploy path, posts-only fix (2026-09-25) Branch `one-core/r7-cli` off `main` `6ee1d336`. There is now one command line, `common/bin/archilyzer.ts`, over the publish layer and the bins. `common/publish/build.ts` has named entry points (`buildSite`, `deploySite`, `buildAll`, `buildHub`, `deployHub`, `composeHub`, `composeHomepage`, `buildHomepage`, `deployHomepage`), and the editor's jobs call the same ones. export's `build` / `build:nodata` twins and the `prebuild` hook are gone. The data phase is now an explicit step, so `pnpm run build` in `export/` can be the CLI without running itself. The hub now has a build and deploy path: on `/sites`, through `pnpm ops`, and through the CLI. The homepage deploys through the CLI. The posts-only 404 is fixed in the composer, and the contract is unchanged. Items 1–8 of the spec are done. `doctor`, `run` and `mcp` were not started (the cut line). | sha | what | |---|---| | `66f138cd` | `_parseFlags.ts` gains `parseArgv(argv, booleans) → {flags, positionals}` beside `parseFlags`. A declared boolean never takes the next word as its value (`build site --nodata jeralyzer`), and a lone `--` is skipped. The skip matters because pnpm 11 hands `pnpm run build -- --nodata` to the script as `-- --nodata`, `--` included (checked in scratch). New `_cli.ts`: `Command = {path, usage, flags?, maxPositionals?, run}`, `resolveCommand` (longest path), `argumentProblem` (unknown flag, a boolean given a value, a string flag given none, extra positionals), `usage`, `runCli` (a refusal exits 2 before `run`). `Command` gained `flags` and `maxPositionals` on top of the spec's `{path, usage, run}`, so a typo such as `--preveiw` is refused before a deploy runs instead of shipping production. The `test` glob gains `bin`. `_cli.test.ts` (9) | | `d97b6ad8` | The ten bins (`build-index`, `build-stats`, `build-chart-templates`, `build-archives`, `compose-site`, `compose-hub`, `compose-homepage`, `sync-tick`, `settings-example`, `file-schemas-docs`) `export async function main(opts)` and auto-run through `runIfEntryPoint(import.meta.url, …)`. That is the `worktree.mjs:350` idiom with both sides realpath'd, because `import.meta.url` is always the real file and argv[1] can reach it through a workspace symlink. A returned number becomes `process.exitCode` without cutting the process short, and a throw prints and exits 1, as before. `compose-site` `main({siteId})` falls back to `SITE_ID` and throws when there is neither. It used to `process.exit(1)`. `sync-tick` `main()` returns 1 on an HTTP error, and `tick()` keeps the old `request failed:` line. `archilyzer.ts` is the table, with lazy `import()` per row. Root `build:index` → `archilyzer index`, `sync:tick` → `archilyzer sync tick`. Flag-driven bins (`_parseFlags` users) are untouched | | `5dabc262` | `buildSiteSteps({siteId, paths, skipData, skipArchives, baseEnv}) → [{command, args, cwd, env}]` lists the steps: `pnpm run build:data` (unless skipData), `pnpm run compose:site`, then `pnpm exec next build`. All three run in `export/` with the old env block (NODE_ENV, TRANSCRIPTS_DIR, EXPORT_PUBLIC_DIR, SITE_ID, and BUILD_ARCHIVES=0 when archives are skipped). The data phase keeps the env the old `prebuild` inherited, EXPORT_PUBLIC_DIR included, rather than `runHostScript`'s narrower one, which matters for the e2e server's `.export-public`. `runBuildPhase` runs the steps with the same `[notice]` lines. Named entry points: `buildSite`, `deploySite` (the preview, project and built-bundle refusals, then R2, then Pages; it throws the deploy job's exact sentences) and `buildAll({mode: "docker"\|"basic"})` (the serial loop lifted from `buildAllSitesAction`). `build-export`, `build-deploy` (its build half), `deploy-export` and `build-all` call them, and their pre-job refusals stay in the actions. export: `prebuild` and `build:nodata` deleted, `build` → `archilyzer build site`, `deploy` → `archilyzer deploy site` (it names the site's project and refuses without one). CLI gains `build site`, `build all` and `deploy site`. `build site` refuses an id with no `site.json`, because a missing site reads as defaults and would otherwise run the whole data phase first. `build.test.ts` +2 | | `e8460a16` | `docker/build-site.sh` → `pnpm --filter yt-dlp-transcript-common exec tsx bin/archilyzer.ts build site "$SITE_ID" --nodata`, `publish-site.sh` → `… build site "$SITE_ID"` | | `077fcba4` | Hub and homepage entry points. `buildHubSteps` runs `compose:hub`, then `next build` with `INSTANCE_MODE=hub`, in `export/`. `buildHub` removes `public/site.json` first. `compose-site` now removes `public/hub-sites.json`, the one other line in that bin, so `out/` says which one it holds. `builtHubProblem(outDir)` (`builtExport.ts`, +1 test): a `site.json` means a site's bundle, even with a stale `hub-sites.json` beside it. `hubProjectProblem` refuses a missing project with the spec's sentence, and refuses `archilyzer` (`HOMEPAGE_PAGES_PROJECT`) by name. `deployHub` reads `getHomepageConfig(paths).cloudflareProject` and runs `pagesDeployArgs` through `runChildIntoLog`, with the `deploymentUrlIn` / `[preview]` line of `runDeployIntoLog`. `buildHomepage` is `pnpm run compose` then `next build` in `homepage/`. `deployHomepage` ships `homepage/out` to `archilyzer` with `--branch main`, as the hardcoded `homepage/package.json` line did (refused when there is no `out/index.html`). CLI: `build hub`, `deploy hub [--preview]`, `build homepage`, `deploy homepage`. export `build:hub` and homepage `deploy` call it. `build.test.ts` +2 | | `c82aad09` | `editor/app/sites/lib/hubActions.ts`: `buildHubAction` (kind `build-hub`, queue `build`), `deployHubAction` (`deploy-hub`, `deploy`) and `buildAndDeployHubAction` (`build-deploy-hub`, `deploy`). Before any job, they refuse a bad preview, a missing project or the homepage's project, and (deploy-only) a bundle that is not the hub. `HubBuildButtons.tsx` sits under the Hub form, in a group named `Hub build`: button **Build hub**, checkbox **Deploy after build** (unchecked by default, unlike the all-sites batch, because the first hub deploy should be a choice), button **Deploy hub**, lanes **Build hub** / **Build & deploy hub** / **Deploy hub**. The Hub section's copy now says the hub and the homepage are two projects. `/api/ops/build-hub` `{deploy?, preview?}` (a preview without deploy is a 400) and `/api/ops/deploy-hub` `{preview?}` (→ `previewUrl`). `archilyzer-ops.mjs` ACTIONS + usage + test (+1). `ops-api.spec` +1: no project, `archilyzer`, and not-a-hub-build are each refused, and no job starts. Hub-URL hints → `https://archilyzer-hub.pages.dev`: `HomepageConfigForm` placeholder (plus an `archilyzer-hub` placeholder on the project field), `SiteForm` Hub URL hint, `settingsSchema` `homepageUrl` (+ `SETTINGS.md` regenerated through `archilyzer settings example`), `mcp/README.md` :457, :508 | | `c66b9d4a` | Homepage hero: **Search all archives** → `homepage.json` `siteUrl` (the field `hubSite()` reads), rendered only when set. `marketing.spec` +1, conditional like the rail test: absent is legal, and a present link must be absolute | | `baa7ef45` | Posts-only 404. When the signature is `""` but `src/manifest.json` exists, `reconcileChannelTree` (now exported) copies the tree under the constant `MANIFEST_ONLY_SIGNATURE`. `corpus.ts` is untouched and spec stays 4. New `bin/compose-site.test.ts` (3): a manifest-only tree is copied (then skipped when unchanged, re-copied once pages arrive), an unchanged tree is skipped, and a member with no manifest is removed and a non-member pruned. Two of the three fail with the fix reverted (checked) | | `e4b5376c` | this record, four `[Unreleased]` bullets | | `bc2d9fb6` | (review fix) `DEPLOY_CLOUDFLARE.md:40-45`: every deploy path uploads to R2 and only a build stages. The review's replacement text said the credentials come from `settings.json`; only the bucket does, and the credentials come from the environment, so the text says that. Settings `homepageUrl` hint and `homepage.ts` comments → `archilyzer-hub` (never `archilyzer`). `compose site` with no id → `siteIdFrom` (one line, exit 2) | | `d1ba90e9` | merge `main` `7b79a945` (slice Y at `2497d20b`). Only `plans/release-7.md` conflicted (both records, Y's first). `editor/CHANGELOG.md` auto-merged, and the lockfile did not move | | *(this commit)* | (review fix) this record: the Pages-project bullet corrected, the 2origin line, rollout step 1 gains `e2e:2origin`, the found-and-left additions, the re-gate | **The CLI as shipped** (`pnpm --filter yt-dlp-transcript-common exec tsx bin/archilyzer.ts …`, or `tsx ../common/bin/archilyzer.ts …` from `export/` / `homepage/`; `--help` anywhere): | command | does | |---|---| | `index` | `buildIndex` (the LMDB index) | | `compose site []` | compose-site `main({siteId})`, id else `SITE_ID` | | `compose hub` / `compose homepage` | compose-hub / compose-homepage `main()` (in-process) | | `build site [] [--nodata] [--skip-archives]` | `buildSite`: data phase + compose + `next build` into `export/out`; refuses an unknown id | | `build all [--skip-archives]` | `buildAll`: docker fan-out when `docker version` answers, else serial host (as the editor's action decides) | | `build hub` | `buildHub` → `export/out` | | `build homepage` | `buildHomepage` → `homepage/out` (reads the index as it stands) | | `deploy site [] [--preview ]` | `deploySite`: refusals, R2, Pages | | `deploy hub [--preview ]` | `deployHub` → `homepage.json` `cloudflareProject` (never `archilyzer`) | | `deploy homepage` | `deployHomepage` → project `archilyzer`, branch `main` | | `sync tick` | sync-tick (`SYNC_TICK_URL`, `SYNC_TICK_TOKEN`) | | `settings example [--check]` | settings-example `main({check})` | Exit codes: 0 ok, 1 failed or refused by the entry point, 2 usage (unknown command or flag, no site). **Gates** (worktree root, final tree before this commit). tsc clean at every commit. common **1771/1771**: 1754 + 9 `_cli` + 4 `build` + 1 `builtExport` + 3 `compose-site`. Editor unit **72/72**. test:scripts **160 pass + 1 skip** (159 + 1 ops). mcp **219/219**. `pnpm --filter editor exec next build` ok (compiled in 29.8 s, `/api/ops/build-hub` and `/api/ops/deploy-hub` listed). `pnpm --filter export exec next build` ok (12.5 s, no dangling `export/public` links). EDITOR e2e `sites-crud site-publish-preview build deploy-page cut-release channel-build-toggle ops-api` (all seven exist; `$T/c-specs.txt`): **49 passed, 0 failed, 2.9 min**, after 1m33s in the queue behind slice Y. EXPORT e2e in full (`node scripts/worktree.mjs run -- pnpm --filter export run e2e`, no dangling links): **192 passed, 0 failed, 7.4 min**. `e2e:hub` (`… pnpm --filter export run e2e:hub`): **8 passed, 16 s**. Homepage e2e (`… pnpm --filter homepage run e2e`, five specs): **15 passed, 7 skipped, 0 failed, 33 s**. The 7 skips are `stats.spec.ts`'s `test.skip(noData, …)`: the worktree has no corpus data. The new hub-link test took its "absent" branch here, because the worktree has no `homepage.json`. Numbers: `plans/tools/phase3-files-numbers.ts` over one frozen copy (`FREEZE_TO`, 71 configs, 1,763 sidecars; `TMPDIR=$T`), `6ee1d336` against this branch: **diff empty** (3,859 lines each). No build, deploy, compose or data build ran against the real corpus. The CLI's refusal paths were exercised in the worktree, which has no corpus: an unknown site, `--preview main`, no id, no project, no hub project and no homepage build. **Re-gate after the review fixes and the merge of `main` `7b79a945`** (tree `d1ba90e9` + record; `rm -rf editor/.next/dev`, lockfile unchanged). tsc clean. common **1789/1789** (1772 on `main` + 17 from this slice). Editor unit **72/72**. test:scripts **160 + 1 skip**. mcp **219/219**. `next build`: editor ok (14.7 s), export ok (7.4 s). Numbers (the same frozen copy) against the `6ee1d336` run: **diff empty**. EDITOR e2e, the seven specs + `pacing.spec` (`$T/c-specs-merged.txt`, `.spec` suffixes): **51 passed, 0 failed, 3.1 min**. EXPORT full: **192 passed, 6.0 min**. `e2e:hub`: **8 passed, 15 s**. `e2e:2origin` with `TWO_ORIGIN_REBUILD=1`: **3 passed, 40 s** (73 s including two `archilyzer build hub` runs). The log shows `$ tsx ../common/bin/archilyzer.ts build hub` and `compose-hub: 0 built-in pool site(s)`. The build runs twice because `globalSetup` is called at config load in the runner and again in the worker, and `TWO_ORIGIN_REBUILD=1` clears the cache each time. That was already true before this slice. The primary's `export/public` files kept their mtimes (checked). Homepage: **15 passed, 7 skipped, 25 s**. **Found and left.** - **No export e2e pins the posts-only fix.** No spec reads `corpus.json`, and the fixture site has only `test-youtube`, with no social channel. The composer is pinned by `compose-site.test.ts`, and the index side (a `pageCount: 0` manifest for a channel with no transcripts) by `editor/e2e/build.spec.ts:10`. The live proof is `curl https://jeralyzer.pages.dev/transcripts/thequartering-X/manifest.json` returning 200 after the next jeralyzer build + deploy. That build needs no `--nodata` caveat, because the staging already has the manifest. - **`Dockerfile.build` needs nothing.** It installs root + common + export `package.json` (tsx is in common's devDependencies, and no `NODE_ENV=production` is set at install), then `COPY . .`, so the CLI and the new `export/package.json` are baked from source. Every docker fan-out calls `ensureBuildImage` (`docker build`, with cached layers reused) before Phase B, and mounts the host `build-site.sh` over the baked one, so the editor's docker path never runs the new script on an old image. Only an image built BEFORE this slice and run by hand, outside the editor, would find `build:nodata` missing. The runtime `Dockerfile` copies all seven `package.json` files and installs dev dependencies in its build stage, so `publish-site.sh` has tsx too. - **A queued deploy now re-checks the bundle when it starts.** `deploySite` / `deployHub` repeat the pre-job refusals inside the job. A deploy queued behind another site's build (which is on the `build` queue, so they do not serialize) used to ship whatever that build left in `export/out`. It now refuses. This is new behaviour, and deliberate. - **Two more hub-URL examples still name `archilyzer.pages.dev`**, outside this slice's files: `editor/app/settings/components/SettingsForm.tsx:48` (the `homepageUrl` hint, which now disagrees with `SETTINGS.md`) and the comment at `common/lib/homepage.ts:31`. Both are one-line changes for slice 3. `DEPLOY_CLOUDFLARE.md:41` still says a raw `pnpm deploy` from `export` only stages archives. `pnpm run deploy` is now `archilyzer deploy site`, which uploads them. That doc is due to be absorbed into `PUBLISH.md` in slice 3. `settingsSchema.ts:417,433` ("basic — `pnpm run build` in export/") is still true, since that script is now the CLI. - **A Pages project must exist before its first deploy.** *(Corrected after review; the first version of this bullet was wrong.)* wrangler offers to create a missing project only when `process.stdin.isTTY` is set. `runChildIntoLog` spawns it with piped stdin, both from an editor job and from the CLI in a terminal, so it never gets that prompt. It fails at once with wrangler's own "The Pages project … does not exist" sentence, and it never hangs. So the projects are created first, with `pnpm dlx wrangler pages project create --production-branch main`. **Done by the parent on 2026-09-25:** `archilyzer-hub` was created (empty). `archilyzer` already existed, with no deployment. Rollout step 9 therefore needs no project creation. Run `pnpm dlx wrangler pages project list` first to confirm both are there. - `build homepage` does not rebuild the index. The homepage's own `pnpm run build` still does, through its `prebuild` (its twins were left as they were, as specified). Run `archilyzer index` first when the index is stale. - The `build-deploy` action's deploy half still calls the `run*` phases directly. It has its own banners (a leading newline, no second built-bundle check straight after its own build), which `deploySite` would change. - `common/lib/builtExport.test.ts` gained a test. It is the test of an owned file, but not on the ownership list by name. - **`export/e2e-2origin/globalSetup.ts:142` runs `pnpm run build:hub`**, which is now `archilyzer build hub`. That command removes `public/site.json` first and sets `NODE_ENV` / `TRANSCRIPTS_DIR` / `EXPORT_PUBLIC_DIR`. The suite caches its hub bundle (it skips the build when `hubA/sw.js` exists), so it was run with `TWO_ORIGIN_REBUILD=1` on the merged tree (see the re-gate below). In a worktree, `export/public` entries are symlinks into the primary, and compose-hub writes through them. For that run, the files the hub build writes or removes (`site.json`, `hub-sites.json`, `corpus.json`, `llms.txt`, `robots.txt`, `_headers`, `sw.js`) were replaced with real copies first, and the symlinks were restored afterwards. The old `build:hub` wrote through them in the same way. - **A production `deploy hub` takes its branch from the git checkout** (no `--branch`). This matches `runDeployIntoLog` for sites, and was left as it is by the parent's decision. Run from a non-`main` checkout, it would become a preview. `deploy homepage` passes `--branch main`. Step 9 runs from the primary on `main`. - **The homepage hero test was not given a positive fixture (review L7, optional).** The homepage suite has no fixture tree. Its `next dev` reads `getPaths()` with no `TRANSCRIPTS_DIR` override, so seeding a `homepage.json` from a spec would write `transcripts/sites/_homepage/homepage.json`: in the primary, the real corpus's file. It stays conditional. Step 9 checks the link live. - **`--preview` takes the next word** (review L6): `deploy site --preview jeralyzer` reads `jeralyzer` as the branch and takes the site from `SITE_ID`. The result is always a preview, never production. The usage text puts `` first. - **Commit trailers** name `Claude Opus 5.5 (1M context)`, as in releases 5 and 6. ### Slice K, as shipped — `pnpm ops keep-videos` (2026-09-25) Branch `one-core/r7-keep` off `main` `3049be43`. The operator's ask: "I've just added the Paramount Tactical channel — mark anything that includes TheQuartering in title or description as 'keep the video', with an ops command." "Keep the video" is the existing per-video **do-not-clean** marker (`data//do-not-clean.json`, `setDoNotClean`). The clean sweep, extra-format cleanup, wrong-format removal, the superseded-subs purge and saved-video eviction already honour it. Before this slice there was only the per-video toggle: no bulk form and no ops action. The slice adds the loop around the marker and no new kind of protection. Two facts shaped it: - **Text lives in two places only.** A downloaded video's `metadata.info.json` is read first, then the channel's `metadata-scan.json` entry. An id known only from `playlist` / `roster.json` has no text and is counted (`unscanned`), not guessed. - **`setDoNotClean` does not mkdir, and nothing here creates `data//`.** A matched video with no dir is reported in `notDownloaded`. Creating the dir would break the scan store's invariant, and every enumerator reads a dir as "fetched". "Matches" is the download filter's matcher. The pattern is compiled by `compileDownloadFilter({include})` and tested by `classifyAgainstFilter` over `downloadFilterText` (title + "\n" + description, with the description capped at 2 KB as the filter caps it), after `downloadFilterPatternProblem` (the form's ReDoS guard). There is no second matcher. `fields` narrows the subject by passing the left-out field as `""`. **The media guard runs first** (`assertChannelMediaReachable`): `listChannelVideoIds` swallows ENOENT, so on an unmounted relocated channel every downloaded match would otherwise read as `notDownloaded`. | sha | what | |---|---| | `8f9b0fc6` | `common/controller/keepVideosMatching.ts`: `keepVideosMatching({paths, channelSlug, pattern, fields?, note?, dryRun?})` returns `{pattern, fields, considered, matched: [{id, title, downloaded, alreadyKept, marked}], marked, alreadyKept, notDownloaded, unscanned, noMetadata, dryRun}`, and `KeepVideosError` covers a bad or unsafe pattern, a bad field and an unknown channel. The default note is `keep-videos: matched //i`. `noMetadata` is **additive to the brief's shape**: a data dir with no `metadata.info.json` and no scan entry has a dir but no text, so it is counted rather than silently skipped. `.test.ts` has 6 cases: title + description-only + case-insensitive; already-kept counted with its mtime and note unchanged; scan-only goes to `notDownloaded` with no dir created and `unscanned` = 2; dry run; `fields:["title"]`; the typed error for `(`, a nested quantifier, an unknown field and an unknown channel | | `22ec333b` | `keepVideosAction` in `videoActions.ts`, beside `toggleDoNotCleanAction`. It maps `KeepVideosError` / `ChannelMediaUnreachableError` to `{ok: false, error}`. When `marked > 0` it calls `revalidatePath` for each marked video and the channel page, and `requestChannelSnapshot` **once** | | `f11c5a72` | `editor/app/api/ops/keep-videos/route.ts`, an adapter with keys `slug, match, fields, note, dryRun` (`reqSlug`; `fields` must be a non-empty array of `"title"`/`"description"`) that returns `{ok: true, ...result}`. `scripts/archilyzer-ops.mjs` ACTIONS gains `keep-videos`, and `archilyzer-ops.test.mjs` +1. `ops-api.spec.ts` +1 covers: a seeded `20240102_keepme12345` titled "Reacting to THEQUARTERING" plus a scan-only `scanonly123` with a description hit. The dry run writes nothing; the real call gives `marked: 1` and `notDownloaded: ["scanonly123"]`, puts the marker only under the matching id, and creates no dir for the scan-only id. An unknown key returns 400 and `(` returns 400 | | `73be3573` | `RUNNING_IN_DOCKER.md`, "Driving the editor without a browser": an example line and a bullet for the two-step reality (`metadata-scan` first, `notDownloaded` then `download-missing`, then re-run) | | *(this commit)* | this record and the `[Unreleased]` bullet | **Gates** (worktree root, on `73be3573`). tsc (`pnpm -r --no-bail --workspace-concurrency=1 exec tsc --noEmit`) was clean on the full tree before the commits were made. The four commits are additive, in dependency order. common **1795/1795** = 1789 + 6 (`keepVideosMatching`). Editor unit **72/72**. test:scripts **161 pass + 1 skip** (160 + 1). mcp **219/219**. `pnpm --filter editor exec next build` ok, and `.next/server/app/api/ops/keep-videos` was emitted. The export build was not run, because the slice touches no file under `export/` and no `common/` module it imports. EDITOR e2e, `$T/k-specs.txt` = `ops-api.spec.ts` (`k-e2e1.log`): **21 passed, 0 failed, 58.9 s**, after a 4 m 58 s queue wait behind the release's final suites. All heavy steps were started only at ≥ 3 GB available memory, as the brief required. **Numbers: none.** No `settings.json`, `site.json` or `config.json` key changed. The action writes only per-video `do-not-clean.json` sidecars, and only when someone runs it. **Not merged with `main` `211d4666`.** The coordinator asked for `git merge main` before the final gates (a test-only commit in the deploy-hub region of `ops-api.spec.ts`). The merge was **refused by the session's permission classifier**, so the branch is still on `3049be43`. `git merge-tree --write-tree HEAD main` reports a **clean** merge. The parent's merge takes it as is, and the `ops-api.spec` run above does not include `211d4666`'s edit. **Found and left.** - **The rule-shaped alternative is not built.** This is a one-shot action: a video downloaded *after* the run is not marked. A persistent per-channel "keep filter" (say `keepFilter.include` in `config.json`, evaluated at download time and by the cleaners) would keep future matches too. That is a schema change (CHANNEL.md, numbers), so it is out of scope here. Until then, re-run `keep-videos` after new downloads. It is idempotent: already-kept videos are counted, not rewritten. - There is no UI surface. The action exists for a future bulk bar or channel-page control. - `keepVideosMatching.ts` has its own 12-line `readPlaylistIds`. The same helper is duplicated privately in `ytdlp/metadataScan.ts` and `controller/recencyIndex.ts`, and neither is exported. A third copy was cheaper than reshaping two files outside this slice's ownership. - `unscanned` compares playlist/roster ids with dir names. A legacy dir named `YYYYMMDD_` does not equal its playlist id, so such a channel over-counts `unscanned`. The count is advisory. - **Commit trailers** name `Claude Opus 5.5 (1M context)`, as the release-6 and release-7 implementers did. ## Rollout 2026-09-25 (afternoon) — `bb3dbb4c` live on :3001 (releases 6 + 7 together) ONE editor restart and ONE umtool restart, at the end of release 7, as the plan said: release 6 changed umtool (4 files), so this rollout rebuilt and restarted both. The live editor had served `93dcb532` (releases 4 + 5, `BUILD_ID` `FKE60BTWpUiUa94WCSxTO`) since the release-5 night. `main` moved during the rollout: `0032ed8a` → `6ee1d336` (this plan) → `2497d20b` (merge Y, tip `e60ac2f4`) → `7b79a945` (plans nit) → `3049be43` (merge C, tip `dbe35c51`) → `211d4666` (the deploy-hub e2e guard, below) → `ac2c4aee` (merge K, tip `374bdae7`, the operator's mid-rollout ask) → **`bb3dbb4c`** (K's two review doc nits) — the sha that went live. Every slice had an Opus read-only review: Y and C were SHIP AFTER FIXES then SHIP on re-review (Y's fixes `8094615d`, `74c24b1f`, `e60ac2f4`; C's `bc2d9fb6`, `d1ba90e9`, `dbe35c51`); K was SHIP AFTER FIXES with two doc nits, applied by the parent in `bb3dbb4c`. **Step 1 — final suites on `3049be43`** (worktree `one-core-r7-cli` detached at the merge sha, ports 3601/3611/3610; logs `final-e2e-r7-{editor,export,hub,2origin}.log`). EDITOR: **626 passed, 5 failed, 12 skipped, 53.1 min**. Slow because the machine sat at 14/15 GB RAM plus 8–14 GB of swap: the LIVE editor's parakeet transcription worker held 2.3 GB. Four of the five failures were load timeouts — `channel-storage.spec.ts:79` (ECONNRESET from the fixture server's apiRequestContext), `site-scope.spec.ts:61`, `sites-crud.spec.ts:148` ("Saved" not visible in 5 s), `social-channel.spec.ts:115` (30 s timeout). **The fifth was real, and it was test isolation:** `ops-api.spec.ts:964` "deploy-hub refuses … a bundle that is not the hub" expected 400 and got 200. `export/out` is the checkout's own build dir (`resolveOutDir` = `paths.exportDir/out`, not fixture-controlled), and C's own `e2e:2origin` run had built a hub into it, so `builtHubProblem` found a hub, the route accepted, and **a deploy job of that hub to `archilyzer-hub`, preview `hub-check`, was started**. Nothing reached Cloudflare — `wrangler pages deployment list --project-name archilyzer-hub` was empty afterwards. Fix **`211d4666`** (test only): the sub-case runs only when `../export/out/hub-sites.json` is absent or a `site.json` is present (mirroring `builtHubProblem`: a site's `site.json` wins); otherwise the test records a `subcase-not-exercisable` annotation and makes NO call with a valid project. EXPORT **192 passed, 6.5 min**. HUB **8 passed, 15.7 s**. 2ORIGIN (`TWO_ORIGIN_REBUILD=1`) **3 passed, 34.3 s**; the worktree's `export/public` entries that compose-hub writes (`hub-sites.json`, `site.json`, `corpus.json`, `llms.txt`, `robots.txt`, `sitemap.xml`, `_headers`) were swapped for real copies before the run and relinked after, so the suites did not touch the primary's files (their 12:59/13:04 mtimes came from the live editor's own Rekietalyzer build, below). **Rerun of the five failed specs on `bb3dbb4c`** (`r7-rerun-editor.log`: `channel-storage ops-api site-scope sites-crud social-channel`): **55 passed, 0 failed, 3.8 min**, including slice K's new ops-api test and the guarded deploy-hub sub-case. tsc on `bb3dbb4c` in the primary (`r7-tsc.out`): 8 errors, **all in the stale generated `editor/.next/dev/types/validator.ts`**, 0 in source; the worktrees, which have no such file, were clean. **Step 2 — install.** `pnpm install --frozen-lockfile` in the primary: "Already up to date", 601 ms (`r7-install.log`). The three facts held: `editor/node_modules/@aws-sdk` absent; `common/node_modules/@aws-sdk/{client-s3,lib-storage}` present; `umtool/report-to-video/node_modules/yt-dlp-transcript-common` → `../../../common`. The OLD live build's `editor/.next/node_modules/@aws-sdk/client-s3-*` resolved into `node_modules/.pnpm/@aws-sdk+client-s3@3.1080.0`. **Step 3 — numbers on the live corpus, new code** (`r7-numbers.log`). Settings: `SETTINGS_RT_OK` **1,353 scalar paths**, diff empty. Files: 77 `unknown keys: []` lines, 0 non-empty, no `WRITE THREW`, **3,859 lines** (`r7-files-numbers.txt`). The plan expected release 5's 3,839; the +20 predates the rollout — C's worktree run before the merge already gave 3,859 over the frozen copy. The corpus grew; no key changed in 6 or 7. **Step 4 — md5 baseline** (`r7-md5.sh`: `settings.json` + 6 `site.json` + every channel `config.json` + `sites/_homepage/homepage.json`). `before` at 12:03: **79 files**. `pre-restart` at 13:10: **80 files** — the operator added `paramount-tactical-videos` during the day. `state.json` `.download|keys` before: `["picks","platformBackoff","runtime"]` on all four lanes. **The reshaped build path ran live before the restart.** At 13:00 the OLD live editor (`93dcb532`) ran job `01M3CQ95WY2DSH5Q2SS2417K1T`, a Rekietalyzer build-deploy. It spawned `pnpm run build` in `export/`, and that script on disk was already C's `tsx ../common/bin/archilyzer.ts build site`. It compiled ("✓ Compiled successfully in 9.8s"), uploaded 81 files and deployed https://51e1101b.rekietalyzer.pages.dev — a real-world pass of the new build path under the old server. It is also what regenerated the primary's `export/public` (Rekietalyzer staging, mtime 12:59:44), which decided step 8's site. **Step 5 — builds** (`r7-builds.log`, 13:09:37, detached while the old server served). Editor: exit 0, **38 s**, `BUILD_ID` `FKE60BTWpUiUa94WCSxTO` → **`6kMVr9Gn2093S9tp_pgqT`**; `readlink -f editor/.next/node_modules/@aws-sdk/client-s3-*` → `node_modules/.pnpm/@aws-sdk+client-s3@3.1080.0/node_modules/@aws-sdk/client-s3`. umtool (cwd `umtool/`): exit 0, **15 s**, `JKtTfoVrUbTosNENO3GMj` → **`5gN2_DKycn5pOV7fNMVos`**. **Step 6 — ONE restart** (`r7-restart.log`, 13:11). Running at the time: only the two runners (auto-transcribe `01M3BAAN42XQ7YJP3F3KDM60DF`, auto-download `01M3BAAN4K2733K0KYTM5BVZSF`) and a transcription of `omnibased/9yXc8WhVZZo`, which was cut and re-picked after boot. Editor: TERM 888092 (pnpm) + 888107 (next-server, cwd `editor/`), `setsid nohup pnpm run start -H 0.0.0.0`, `/` 200 after **2 s**, "Ready in 175ms" (`editor-start-r7.log`), `/tags` 200. umtool: TERM 5863 + 5881, its `start` on :3050, `/` 200 after **3 s** (`umtool-start-r7.log`). **Step 7 — after boot.** md5 `after-boot`: **80 files, identical** to pre-restart — boot rewrote nothing. `ZodError` 0; umtool errors 0. `state.json`: every lane now carries `videoDeferrals` (`{}`) beside `picks` / `platformBackoff` / `runtime` — the expected change outside the md5 baseline. `/api/view/autoQueueStatus` `.download.deferred == []`, `.transcription.deferred == []`. **Step 8 — smoke** (`r7-smoke.out`, 13:13:48 → 13:16:28, **`SMOKE_FAIL=0`**). All eight `/api/*` ↔ `/api/view/*` pairs identical with the live fields stripped (`autoQueueStatus` 511,533 B); `/api/view/bogus` and `/api/view/invalidate-cache` 404; presets 200; `?rev=` `changed=false` on both paths; ten pages 200 (`/` 4 s, `/channels`, `?sort=size`, `/jobs` 3 s, `/operations/diarization` 110 s, `/settings`, `/storage`, `/tags`, `/channels/FearAnd`, the video page). `/operations/download` 200 with **one** `Rate-limit cooldown` region — the plan said none "while nothing cools", and youtube WAS cooling: the persisted state carried `fails: 12` from the old build's loop (step 12), so the region is correct. `/sites` 200 with `Build hub` and `Deploy hub`; umtool `/` 200 on the new `BUILD_ID`. **Site build:** `pnpm ops build-site --json '{"siteId":"rekietalyzer","skipData":true,"skipArchives":true}' --wait` (`r7-ops-build-site.log`): **exit 0, 27 s, compiled, 0 `prebuild` lines, 0 `build:data` lines** — the SDK resolves from common and the build is compose + `next build` with no data phase. **Rekietalyzer, not the plan's anilyzer:** a `skipData` build composes over whatever is staged, and the primary's `export/public` held the 13:00 Rekietalyzer staging; anilyzer over it would have been a wrong bundle. **Step 9 — hub and homepage, the first deploys ever.** The Pages project `archilyzer-hub` did not exist; the parent created it beforehand (`wrangler pages project create archilyzer-hub --production-branch main`, ~11:5x), because wrangler never gets a TTY from a job or the CLI and fails fast on a missing project (C's record). `archilyzer` existed with no deployment — hence the 522. **Form** (`r7-hub-form.mjs`, Playwright on the live `/sites` Hub form): `cloudflareProject` `archilyzer` → `archilyzer-hub`, `siteUrl` blank → `https://archilyzer-hub.pages.dev`, "Saved."; both persisted across a reload; md5 `after-form`: only `homepage.json` moved. **Build:** `pnpm ops build-hub --json '{}' --wait` (`r7-ops-build-hub.log`): exit 0, **41 s**, `export/out/hub-sites.json` with **5 members**, `corpus.json` present. **Deploy:** `pnpm ops deploy-hub --json '{}' --wait` (`r7-ops-deploy-hub.log`): exit 0, **750 s** ("Uploaded 548 files (726.96 sec)" — the first upload of every file), https://f73a7c21.archilyzer-hub.pages.dev. **Public:** `https://archilyzer-hub.pages.dev/hub-sites.json` 200 (579 B: anilyzer, bonnellyzer, hasanalyzer, jeralyzer, rekietalyzer), `/corpus.json` 200 (spec 4, 5 members), `/llms.txt` 200, `/` 200. **Browser proof** (`h-hub-check.mjs`, `h-hub-report.md`, `h-hub-search.png`, `h-hub-modal.png`): the shelf shows all 5 members badged "Member"; "Quartering" (metadata scope) hit only 1 site, so "lawsuit" was used — **5 results spanning 4 origins** (jeralyzer, anilyzer, rekietalyzer, bonnellyzer); the first result's transcript modal opened; the three export controls of `export/e2e-hub/transcript-downloads.spec.ts` ("Mark both clip start and end first", "Download this transcript as a file", "Copy this transcript as Markdown (for AI)") each count **0**; the share link is present. **MCP proof** (`h-mcp-list-sources.mjs`, the package's own smoke-harness pattern): the server spawned with `TRANSCRIPT_HUB_URL=https://archilyzer-hub.pages.dev` logged `default corpus: hub:https://archilyzer-hub.pages.dev`, and `list_sources` listed the 5 members as `remote:` handles. **Homepage:** `pnpm --filter yt-dlp-transcript-common exec tsx bin/archilyzer.ts build homepage` exit 0, **41 s** (`r7-cli-build-homepage.log`); `… deploy homepage` exit 0, **33 s** → https://6508d288.archilyzer.pages.dev (`r7-cli-deploy-homepage.log`). Public: `https://archilyzer.pages.dev/` **200 (was 522)**, `/docs/install/` 200, `/downloads/archilyzer-source.tar.gz` 200 (2,541,513 B); the hero shows "Search all archives" → `https://archilyzer-hub.pages.dev`. **Step 10 — the owed sync + md5.** `teamrcn` (7 videos, the smallest YouTube video channel) is queued behind the youtube cooldown by `r7-scan-sync2.sh` (log `r7-scan-sync2.log`), after the Paramount Tactical metadata scan; `config.json` before: `r7-teamrcn-config.before.json`. The sync ran at 15:21 after release 8's restart (job `01M3CY7BN3DNN12T93X9GZM48X`, queued at 14:46 behind the paramount-tactical scan, which the operator cancelled at 377/1,382 to let downloads start). It did its work — full sweep, availability check (1 dir, 0 attempted), backfill wrote 0 — and `teamrcn/config.json` moved on exactly two keys: `lastSyncedAt` 2026-05-18 → 2026-09-25T19:21:15.754Z and a new `lastFullSweepAt` 2026-09-25T19:21:15.762Z; no other file changed. Its STATUS reads `failed`, though: the last line is `[error] Invariant: static generation store missing in revalidatePath /channels/teamrcn`. That is the pipeline action's own body calling `revalidatePath` at its end (`editor/app/channels/[slug]/pipelineActions.ts:220-224`, the same body download-missing runs) from the platform queue's drain loop, where Next has no request store, because the job was QUEUED rather than started inside the ops request; every earlier ops sync ran immediately (empty queue) and never hit it. New finding, recorded in release 8's rollout; fix owed (tolerate a missing store in the job-completion revalidation). **Step 11 — 410 re-read: not observed today.** No availability check reached `rekietalaw-rumble/v7e07us` or the four quartering ids. Left to their next check, as the plan says (observe, do not force). **Step 12 — pacing, proved live the same afternoon.** The persisted state at boot carried `download.platformBackoff.youtube = {fails: 12}` from the OLD build's loop on `paramount-tactical-videos/_60iFE_FBPQ`: the auto-download lane had started on the operator's new 1,473-video channel, the subtitle fetch 429'd, and the old runner re-picked the same video after every cooldown — the plan's Short, again. The old runner's line: Auto-download: youtube rate_limit — backing off 1658s (attempt 12). _60iFE_FBPQ will retry after cooldown. The cooldown lapsed at 13:20:12; the NEW runner re-picked `_60iFE_FBPQ` (not yet deferred — the old build never wrote a deferral), it 429'd, and the log reads: Auto-download: youtube rate_limit — backing off 1949s (attempt 13). _60iFE_FBPQ deferred 6h; next video after cooldown. `state.json` then held `download.videoDeferrals = {"_60iFE_FBPQ": {"until": <+6 h>, "channelSlug": "paramount-tactical-videos"}}` and `platformBackoff.youtube = {fails: 13, until: 13:52:55}`. The next pick after this cooldown is a different video. The manual `metadata-scan` and `sync` submitted during the cooldown were refused with the platform-cooldown sentence (`r7-scan-sync.log`) — unchanged behaviour, as the plan says. **(iii), the evening watch, is still owed:** `grep -h 'deferred 6h' transcripts/.jobs/*.log` — each id at most once per 6 h, `attempt N` climbing only across distinct ids. The scan-error refresh proof (a second restart within 24 h logs no `legal-mindset` re-scan) is not provable today: there was one restart. **And then the disk lost it (13:33:37).** `state.json` was rewritten with the boot-time values (`fails: 12`, `until` 13:20:03, `videoDeferrals: {}`): the download runner's 13:20 persist was clobbered by another lane's persist of ITS copy of the whole file. Traced the same afternoon (`$T/q-state-revert.md`): `sharedAutoQueueState` (`autoRunner.ts:268-293`) caches the resolved value, not the in-flight read, so the four lanes started together at boot each read the file themselves and hold a private object for life; `writeAutoQueueState` writes all four lanes from whichever copy persists. The rename at 13:29 recompiled the priority roots and gave a stale-holding lane work, hence the 13:33 write. Pre-existing (the comment above that function describes this exact bug as fixed), not introduced by Y; Y made it visible because a deferral is a value worth keeping. The running download lane still holds its deferral in memory; a restart would lose it. Fix = release 8 slice S (`one-core/r8-state-share`): cache the promise. The record above states what was true at 13:21. **Found and left.** - **The operator asked: "more 429s than ever — are there multiple YouTube downloads at once?"** No (`q-429-report.md`). Every YouTube-touching job kind lands on ONE `platform:youtube` queue, submitted with `concurrency: 1` (`common/jobs/registry.ts:189-211`, `common/lib/queueKeys.ts:69-73`); 122 YouTube jobs in the retained 36 h, **zero overlaps**. All 26 real 429s are subtitle fetches, on `quarteringvlogs` (20) and paramount-tactical, and the old re-pick loop multiplied them — which is exactly what slice Y removes. Gap found: YouTube has no `--sleep-requests` in `common/ytdlp/platformArgs.mjs` (Rumble has `1`) — a candidate follow-up, **not applied**. Dormant concurrency gaps: `check-kept-deleted` runs on channel keys, not the platform queue; the backfill re-acquire runs on its own queue (disabled, `backfill.allowRedownload: false`). - **The operator asked: "does `downloadFilter.include` have a UI?"** Yes — the channel Configure form's "Download filter: include (regex)" field (`editor/app/channels/components/ChannelForm.tsx:702`). - **The channel was renamed** `paramount-tactical-videos` → `paramount-tactical` by the operator at ~13:29 (8 data dirs at that time). The live deferral keeps the old slug — harmless: it expires in 6 h and the pick filter is by video id. `RUNNING_IN_DOCKER.md`'s keep-videos example named the old slug in `bb3dbb4c`; this record's commit corrects it. - **Slice K's live run is pending the scan.** `pnpm ops keep-videos` on `paramount-tactical` (`match: "TheQuartering"`) needs the metadata scan first (0 of 1,472 scanned at 12:52); the scan is queued in `r7-scan-sync2.sh` ahead of the teamrcn sync. - **Worktrees to remove:** `one-core-r7-pacing`, `one-core-r7-cli` (detached), `one-core-r7-keep`, plus the older `one-core-r5-*`, `one-core-r6-followups`, `one-core-phase-4-s1`. `pnpm wt list`, then `git worktree remove `; removing shifts port blocks. - **The `.next/dev/types` tsc noise in the primary** is a stale generated file, not source; `rm -rf editor/.next/dev` clears it (as Y's re-gate did in its worktree). Not done here — the live `.next` is the running server's. - The e2e isolation lesson is in FACTS: **`export/out` belongs to the checkout, not the fixture**, so a spec that can reach a deploy must check what is built before it calls with a valid project.