# Release 10 — the brand: the Found-line mark, and base × accent reader themes `main` at `c9223978` (release 9 live on :3001 since 2026-09-25 19:40 as `0213f6c8`; C1b/C2/C3 deployed to the hub and the homepage the same evening). Release 10's first content is the brand the operator asked for on 2026-09-25: one mark and one wordmark for Archilyzer and every official site, and a reader theme made of two independent choices, a **base** (System / Light / Sepia / Dark) and an **accent** (the site's own by default, any of seven by choice). The five theme families retire. The plan, the design decisions and the per-slice records are [`brand-and-themes.md`](brand-and-themes.md); this file records the release as a whole. Rules: `plans/tools/implementer-rules.md`. **The release-10 "lows" joined the release** (2026-09-26): they shipped as slices L1 (hub) and L2 (runner), and merged to `main` with brand S4 before the rollout, on the operator's word. Their records and the integration pass that gated the three together are below ("Slice L2", "Slice L1", "Integration (S4 + L1 + L2), as merged"). ## Record ### The brand, slices S0–S3 (2026-09-25) Four slices, each one Opus implementer and one Opus review, under the plan's dependency graph: S0 alone, then S1 and S2 in parallel worktrees off S0's tip, then S3 on the merged tree. | Slice | Branch → tip | Review | Merged to `main` | |---|---|---|---| | **S0** palette + data | `brand/found-line` off `d8dd98b8` → `02295a94` (+ `dbf12219`, the Archilyzer Media proposal) | SHIP | `7c9e3bdf` | | **S1** mark, icons, wordmark | `brand/mark` off `02295a94` → `03a0ce06` | SHIP (fixes `541a46e0`, `405fccd5`, `951a0ff5`, `4a7641af`; re-read SHIP) | `575ae1d4` | | **S2** base × accent | `brand/themes` off `02295a94` → `42e84f68` (merged `main` as `6456ac6c`) | SHIP AFTER FIXES (7 fixes; re-read SHIP) | `b9772e53` | | **S3** integrate | `brand/found-line` fast-forwarded to `b9772e53` → `85cf6e81` + the `plans:` records | SHIP AFTER FIXES (the fixes were in the runbook; test nits `85cf6e81`) | fast-forward | **Gates per slice** (full numbers in each slice's record in `brand-and-themes.md`): | Slice | Unit + script tests | Builds | e2e | |---|---|---|---| | S0 | common 1,874, editor unit 78, scripts 162 + 1 skip, mcp 219; SITE.md `--check` clean | editor, export, homepage ok | editor `sites-crud` + `branding` 16/16 (run 2; run 1's failure was the new test's own locator), export `site-branding` 7/7, `e2e:hub` 12/12 | | S1 | common 1,881 → 1,887 (review fixes), editor unit 79, scripts 162 + 1, mcp 219 | editor, export (site, hub, a brass demo), homepage ok; icons + ICO magic checked | export full 199 + 1 (`theme-family.spec:18`, a click-before-hydration race in a spec S2 deleted; rerun 6/6), `e2e:hub` 14, homepage 26, editor 48, `e2e:2origin` 3; review re-gate 8 + 14 | | S2 | common 1,893 → 1,907 after merging `main`, editor unit 79, scripts 162 + 1, mcp 219 | editor, export (site, hub), homepage ok | pre-merge: export full 199, hub 12, homepage 24, editor 22; post-merge: export full 203 + 1 (a ready-marker race, fixed `e3e3fb26`, then 39/39 over three repeats), hub 19, 2origin 3, homepage 27, editor 23 | | S3 | common 1,913, editor unit 79, scripts 162 + 1, mcp 219 | editor, export (site, hub), homepage ok, twice | below: export 204, hub 19, 2origin 3, homepage 27, **editor full 638 + 1 flaky (rerun 3/3), 12 skipped** | **What shipped, in one place:** - **S0 — the brand as data.** `common/lib/brand.ts` (seven named accents with a value per ground, each ≥ 4.5:1 against its ground and its ink; the three grounds; the Found-line mark as a shape list; `markSvg`; the subject-split wordmark). `site.json` takes `accent` as an id or a hex and a new `wordmarkLead` (a proper prefix of `headerTitle`, else dropped). The published accent stays a hex (`accentHex` in `/site.json`, `hub-sites.json`, the homepage summary). The editor's site form has the swatch radio group + Custom hex and the Wordmark lead field. - **S1 — the mark everywhere.** Every icon file (`/icons/*`, `/favicon.ico`) is rendered at build by force-static route handlers (`next/og` PNGs, a PNG-payload ICO), lit with the site's accent; the hub, the homepage and the editor wear the parent mark (bone on slate). Header = `BrandMark` + `Wordmark`; the footer credit carries the parent mark; the editor gets a favicon. The manifest is on ink. Both service workers: `SHELL = "shell-v2"`, `/icons/` network-first, `VERSION` unchanged. - **S2 — base × accent.** `tokens.css` is three base blocks × eight accent rules (the seven + custom), with fixed validated charts per base; Archivo / IBM Plex Sans / IBM Plex Mono; the pre-paint script migrates the retired theme/mode keys once; `ThemeMenu` (Base + Accent), `ThemeToggle` (System → Light → Sepia → Dark), the phone sheet's two native radio groups; the hub and homepage render dark on the server. - **S3 — integrate** (below). **The absent-accent contract: no change (decided 2026-09-25, S3).** A site with no `accent` renders in Signal, but its published `/site.json`, `hub-sites.json` entry and homepage-summary entry still carry **no** `accent` key, rather than Signal's hex. Publishing one would change what third-party hubs draw for every accent-less site they already list; today they keep their own fallback, as before the brand. The official sites get explicit accents at rollout (the settings table below), so the question is moot for them. Revisit only if a hub asks for it. ### Slice S3, as shipped — integrate (2026-09-25) The full record — every carry-over with its numbers, the coherence pass, the visual set, found and left — is [`brand-and-themes.md`](brand-and-themes.md) "Slice S3, as shipped". In short: - **Review carry-overs, all done:** the brand tests pin the literal 4.5 bar, the sRGB boundary pair and all 21 accent values; the service-worker test covers a non-ok and a failed icon fetch; Light's success and warning read ≥ 4.5:1 on their own soft fills (`#1c7046` 4.74 / 5.12, `#825809` 4.75 / 5.14); `geometricPrecision` is scoped to **1.5dppx** (measured: words split from 1.5x, not only 2x); two stale comments fixed; the absent-accent decision recorded above. - **Coherence pass:** four stale "theme family" comments and the accent hint fixed; the three `[Unreleased]` changelogs rewritten as one release (release 9's bullets untouched). - **A build defect the gates found, fixed:** Google Fonts at times serves IBM Plex Sans's static weights through `https://fonts.gstatic.com/l/font?kit=…&skey=…`, which Turbopack's `next/font/google` cannot load, so `next dev` / `next build` failed intermittently (S1's first homepage build, S3's first homepage e2e). Plex Sans is now requested variable (`wght` 100–700, identical outlines and advances), whose file is always a pre-built `/s/` URL. | sha | what | |---|---| | `6497439d` | brand tests: the literal 4.5 bar, the sRGB boundary pair, all 21 accent values; `accent.test` the literal bar | | `53a692ac` | `serviceWorkerRouting.test`: a non-ok and a failed online icon fetch leave the cached icon, both workers | | `1da6a265` | light success `#1c7046` / warning `#825809` (+ soft fills); `themeTokens.test` text-on-soft on every base | | `408120a3` | `geometricPrecision` under `@media (min-resolution: 2dppx)` + the test (superseded by `dc7ff1cf`) | | `58e71a88` | `export/app/lib/site.ts` comment wrap; `playwright.config.ts`'s sw.js comment by its code, no committed icons | | `a0790d39` | stale "theme family" comments (`ui/alert`, `laneState`, `WorkersView`) and the site form's accent hint | | `25952f12` | the editor/export/homepage `[Unreleased]` brand bullets as one coherent release | | `dc7ff1cf` | the scope starts at 1.5dppx (measured), test updated | | `a4542721` | `fonts.ts`: IBM Plex Sans requested variable (the `l/font` build failure) | | `2c76f6b2` | the `tokens.css` comment: why Plex hints on Linux, why the scope starts at 1.5x | | `5a433aa9` | `plans:` the S3 record, `release-10.md`, FACTS, STATE, the rollout section | | `85cf6e81` | review nits: the token parser comment; the SW test's no-cache network-error case | | _this_ | `plans:` the review fixes | **Gates** (the code tip is `2c76f6b2`). - **tsc** clean before every code commit (four runs: `s3-tsc-1..4.log`; the changelog commit is markdown). - **Unit and script tests** on `25952f12`: common **1,913/1,913** (1,907 + 6: brand +1, service worker +4, tokens +1), editor unit **79/79**, `test:scripts` **162 + 1 skip**, mcp **219/219**; `file-schemas-docs.ts --check` clean. On `2c76f6b2`: common **1,913/1,913** again (the font request touches nothing the other three test). - **Builds**, `export/public` seeded under sh with `sw.js` a plain copy and no dangling links, `homepage/public` data copied from the primary: - on `25952f12` (`s3-gates.log`): editor ok (33 s), export site ok (22 s), export hub ok (19 s), homepage ok (13 s); - on `2c76f6b2` (`s3-regate.log`): editor ok (43 s), export site ok (26 s), homepage ok (14 s), export hub ok (27 s); - every export/homepage build: `out/icons` = the seven files, PNG magic `89504e470d0a1a0a` and IHDR 180/192/32/512/512, `favicon.ico` `00 00 01 00 03 00`; the site's `icon.svg` lit Signal `#5fa8a0`, the hub's and homepage's the parent mark; the site `` carries `data-accent="signal"` and no `data-base` (theme-color pair `#f3f6f7` / `#0c0a08`), the hub and homepage `dark data-base="dark" data-accent="signal"`; the built CSS has `@media (min-resolution:1.5x){html,button, input,select,textarea{text-rendering:geometricprecision}}`. - **e2e, the full sequence on `dc7ff1cf`** (`s3-e2e.log`, 23:22 → 00:10, no queue wait): - export full **204 passed**, 6.4 min; - `e2e:hub` **19 passed**, 37 s; - `e2e:2origin` (`TWO_ORIGIN_REBUILD=1`, the seven compose outputs + `sw.js` swapped for copies, relinked after) **3 passed**, 33 s; - homepage full: **did not start** — the webServer timed out at 120 s on the font defect above; - **editor full (`pnpm e2e`) 638 passed, 1 failed, 12 skipped, 38.2 min** (651 tests). The failure is `pipeline.spec.ts:164` "channel list sync button runs the platform-queue sync", 355 ms: `EEXIST: file already exists, mkdir '…/editor/test-transcripts/channels'` inside `resetData`'s fixture copy (`editor/e2e/helpers.ts`), before the test touched the page. **Pre-existing, flaky:** it is the fixture-reset race the helper's own comment describes (a server runner re-creating a path while the tree is reset; the same class hit `channel-work.spec` and `pipeline.spec:106` before), and the brand diff touches no helper, runner, channel list or job code (`git diff --stat c9223978 HEAD -- editor/e2e/helpers.ts editor/app/channels common/jobs common/controller` is empty). Rerun alone ×3 on the final tree: **3 passed**. - **e2e re-gate on `2c76f6b2`** (`s3-regate.log`, 00:14 → 00:26, after the font fix): - homepage full **27 passed**, 42 s; - editor `pipeline.spec.ts:164 --repeat-each 3` **3 passed**, 39 s; - editor `theme.spec.ts branding.spec.ts sites-crud.spec.ts` **23 passed**, 58 s; - export full **204 passed**, 7.2 min; - `e2e:hub` **19 passed**, 43 s; - `e2e:2origin` (copies swapped in and relinked as above) **3 passed**, 41 s. - The full editor suite was not re-run after `a4542721`/`2c76f6b2`: they change only which Google Fonts URL `next/font` requests (same outlines) and a CSS comment. The suites that compile those fonts in `next dev` (export, homepage) ran in full on the final tree. - **The primary checkout's `export/public` was never written:** `sw.js` 10,027 B, mtime 20:47:37, and `hub-summary.json`, `hub-sites.json`, `corpus.json`, `llms.txt`, `robots.txt`, `sitemap.xml` and `_headers` kept their sizes and mtimes before, between and after every run. - Numbers tools: none. **Review** (SHIP AFTER FIXES): the must-fix and should-fix were in the runbook, not the code. The runbook now checks that the brand's final commit is in the primary's HEAD (it had checked S2's merge, which passed while S3 was unmerged), and every rollout script is fail-stop: no homepage deploy after a failed build, no hub step after a failed sites run, a skipped site named, no restart on the pre-brand `BUILD_ID`. Details: `brand-and-themes.md`, S3 "Review fixes". ## Planned: the lows, L1 ‖ L2 (2026-09-26) These are the release-10 candidates from `STATE.md`, found on the evening of 2026-09-25. Their details are in [`release-9.md`](release-9.md) around :219, :247–300, :459, :577–580 and :650–675. They run as two parallel slices, alongside brand S4 (umtool + `common/bin`; see [`brand-and-themes.md`](brand-and-themes.md)). ``` main (4e5630aa) ──┬──> L1 hub lows (r10/hub-lows) ──┐ ├──> L2 runner lows (r10/runner-lows) ──┼──> reviews ──> merge after the brand rollout └──> brand S4 (brand/media) ──┘ (or earlier, on the operator's word + a re-cut) ``` **Merge timing.** The operator cut editor/export 0.9.0 for the brand rollout, and the runbook builds `main`. The lows therefore stay on their branches after review, and merge once the brand is rolled out. If the operator asks for them sooner, export needs another cut, because its `[Unreleased]` notes are public on `/changelog`. ### L1 — hub lows (`r10/hub-lows`) 1. **`retry: false` on the hub's subs query.** A member with no subs corpus is ready about 1 s late (release 9 C2 re-read). 2. **`/ask` on the hub honours the scope chips.** Use `useHubScope().isOn` in `AskHub`. It still waits for every archive to settle. 3. **Official sites' accents on the hub.** A shared `seriesColor` fallback for the result stripes and chip dots, for an official site with no accent. This needs `hub-summary.json`'s order. The rollout's accents fix the cards' stripes; this covers everything else and any site without an accent. 4. **One order for the official instances.** The hub orders its cards alphabetically (by `hub-sites.json`), the homepage by transcripts, so the same colours land on different rows. The hub follows the homepage, which puts `seriesColor` and the rows in the same order. If the code shows a reason to prefer alphabetical, say so instead. 5. **`export/playwright.config.ts` unlinks `public/sw.js` before copying it.** In a worktree that path is a link into the primary checkout, and a worktree export e2e run overwrites the primary's hub service worker (FACTS 2026-09-25). L1 owns: `export/app/components/hub/**`, the hub search/scope files, `homepage/**` (ordering only), `export/playwright.config.ts`, and `export/CHANGELOG.md`. ### L2 — runner lows (`r10/runner-lows`) 6. **YouTube's soft block backs off.** "Try again later" classifies as `deleted` → per_video, so it never backs off. Classify it as a rate/bot condition that backs off, and keep genuinely deleted videos per_video. See the release 9 F record. 7. **The boot pass's wait on the storage pass gets a timeout.** Log it when the timeout fires, and let the boot pass continue. 8. **`/jobs` shows `cancelReason`.** It is already in the meta and the log (release 9 B4b); draw it on the job row / detail. 9. **The safeRevalidate warning fires per occurrence, not once per bundle.** Rate-limit or count it, rather than printing it once per module load. L2 owns: `common/controller/**` and `common/jobs/**` for 6–7, the editor `/jobs` UI, and the safeRevalidate helper. ### Shared, and whoever merges later resolves it - `editor/CHANGELOG.md`. Add a NEW `## [Unreleased]` above `[0.9.0]`, and never edit `[0.9.0]`. - This file's records. - Neither slice touches S4's files: `common/lib/brandMedia*`, `common/bin/brand-media.ts`, `common/bin/gen-media-glyphs.py`, `umtool/report-to-video/**`. ### Gates, per slice - tsc. - Unit tests: common, editor, `test:scripts`, mcp. - **L1:** `next build` for editor, export (site and hub) and homepage; the full export suite, `e2e:hub`, `e2e:2origin` and the homepage e2e. Also prove item 5: after a worktree export e2e, the primary's `export/public/sw.js` is untouched. - **L2:** the editor `next build`, and the editor e2e specs for `/jobs`, boot and downloads (the full editor suite runs at integration). Unit tests for the classification (6) and the timeout (7). **S4 — Archilyzer Media** (the YouTube channel's assets and umtool's opt-in `render.brand` preset) merges after the release-10 cut, from `brand/media`, and is **not part of the site rollout**: it touches only `common/lib` + `common/bin` and umtool; see `brand-and-themes.md`, "Slice S4, as shipped". (Merged 2026-09-26 as `dcb04f61`; its one rollout step is the optional umtool rebuild, "Rollout" 2b.) ### Slice L2, as shipped — runner lows (2026-09-26) Branch `r10/runner-lows` off `main` `5dfc9c3a`, one Opus implementer, beside L1 (hub lows) and brand S4. Items 6–9 of "Planned: the lows". Nothing on disk moves, and no settings, site or channel key changes; `JobListEntry.cancelReason` and `JobRowView.cancelReason` are additive and optional. **6 — YouTube's soft block backs off.** YouTube answers a session it is rate-limiting with the playability reason "This content isn't available, try again later." Its "content isn't available" matched `parseUnavailableFromStderr`'s `deleted` group, so it was `per_video`: no platform cooldown, the batch kept requesting into the block, and the video joined `EXCLUDED_FROM_DOWNLOAD` as gone. `isSoftBlock` (`common/lib/availability.ts`, `/isn['’]?t available,? try again later/i`) is now checked first in both functions: `parseUnavailableFromStderr` → `error` (transient: not excluded, not pinned as gone), `classifyDownloadFailure` → `rate_limit`, even over a per-video class a caller already holds. What that drives is the existing path, unchanged: `runManagedDownloads` records the platform cooldown and aborts the batch; the auto runner's `applyUnitOutcome` backs the platform off and defers the video 6 h; `fetchWindowManaged` records the cooldown; the metadata scan stops the pass (its block `kind` is `"soft-block"` for this line, which only changes the log wording; the one cookie retry every block gets is unchanged). "Try again later" is what marks it. A bare "Video unavailable", "…removed by the uploader", a terminated account, a ToS removal and Rumble's `HTTP Error 410: Gone` stay `deleted` / `per_video`, and so does "This content isn't available." with no retry advice. Every failure still sleeps between downloads; the release 9 comments that gave the soft block as the reason now say why the pace stays anyway. - **The strings, and where they came from.** No sidecar on the live corpus holds one (read-only grep, 2026-09-26: 136,401 `availability.json` / `download-outcome.json` files and every `metadata-scan.json`, zero hits for "try again later", "content isn't available" or "rate-limited by YouTube"). The tests use: - `ERROR: [youtube] H64QQZuw-aA: This content isn't available, try again later.` — the line reported in yt-dlp issue #11426 (what a yt-dlp before #12958 prints); - `ERROR: [youtube] : This content isn't available, try again later. The current session has been rate-limited by YouTube for up to an hour. It is recommended to use `-t sleep` to add a delay between video requests to avoid exceeding the rate limit. For more information, refer to https://github.com/yt-dlp/yt-dlp/wiki/Extractors#this-content-isnt-available-try-again-later` — built by `yt_dlp/extractor/youtube/_video.py` (yt-dlp #12958, commit `26feac3dd`) in the build the editor runs (`~/Projects/yt-dlp-patched`, 2026.08.19); "Your account" instead of "The current session" when cookies were passed; - the bare line with a right single quote. yt-dlp's wiki puts the limit at ~300 videos/hour for a guest session, ~2,000 signed in. **7 — the boot pass's wait on the storage pass is bounded.** `settleAfterStoragePass` (`common/jobs/bootQueuedJobs.ts`) waits for the storage pass for at most `STORAGE_PASS_WAIT_MS`, then settles anyway; `instrumentation.ts` calls it in place of `storagePass.then(settle…)`. - **60 s, and why.** A healthy pass takes milliseconds: `findmnt -T` answers in under 10 ms on this machine. Its bounded worst case is two or three `findmnt`s per location at `FINDMNT_TIMEOUT_MS` (3 s) — identity and fstab, or where the uuid is mounted — plus, for a location being re-pointed, the preflight's second probe and a stat per channel on it: about 12 s a location. Nothing bounds the `stat` of a location's root or its statfs, and on a hung network mount they never return. 60 s covers several locations at their worst; past it the pass is stuck on a syscall, and waiting buys nothing. - **When it fires:** `[boot] storage pass still running after 60 s; settling queued jobs without it (a hung mount? a job re-queued for a channel on it will wait on the same mount, and the re-queues after it with it)`, and, when the pass finally ends, `[boot] storage pass finished N s after the queued-job pass began waiting (it stopped waiting at 60 s)`. The race is over a derived promise, so the storage pass is never cancelled and a re-point it enqueued runs to the end. A pass that throws counts as finished. - **What a re-queue then meets** (corrected in review; as first shipped this said every such job is refused at once). For an UNMOUNTED drive, the media guard's `stat` gets ENOENT at once, so the job is refused — at submission, closing the old meta `cancelled` with the error, or when it starts — and `/jobs` says so. For a HUNG mount, the case the bound exists for, the guard's own `stat` (`lib/channelMedia.ts`) hangs on the same syscall; re-queues run one at a time, so the ones after it stay `queued` until the mount answers. Every cancel has run by then. Left: see below. **8 — `/jobs` shows `cancelReason`.** `JobListEntry.cancelReason` is read from the meta only when its status is `cancelled`; `fromEntry` copies it to `JobRowView.cancelReason`. `JobRow.tsx` draws it wherever the cancelled pill is: under the pill in the table's Status cell, at the end of a card row's heading, and as the pill's `title` on a compact row. The job page (`/jobs/[id]`) adds a full-width **Cancelled because** cell. All carry `data-testid="cancel-reason"`. Everything is added; no existing label, text or test id changed (`editor/e2e` had no `cancel-reason`, `Cancelled because` or detail-page cell assertions to collide with). A live row never has one: only the boot pass writes it, to metas from before the boot. **9 — the safeRevalidate warning is counted.** `SkipReporter` (`editor/app/lib/safeRevalidate.ts`), one per process on `globalThis.__yttSafeRevalidateSkips__` (LOW-4: one per module instance was not even once). A skip is one `safeRevalidate` call with no request store, however many targets it names. The first after a quiet spell is logged at once with its paths, under the old prefix, and opens a `SKIP_REPORT_WINDOW_MS` (10 min) window. The rest of the window are counted, and one line at its end reports them: `[safeRevalidate] N more skip(s) with no request store in the last 10 min (latest: …); T since this process started.` So at most two lines per window. The end-of-window timer is unref'd. It is not reset by `/api/test/invalidate-cache`: it decides only how many log lines a skip costs, and nothing reads that log. | sha | what | |---|---| | `deaff1d9` | 6: `isSoftBlock`; `parseUnavailableFromStderr` → `error`, `classifyDownloadFailure` → `rate_limit`; the scan's `soft-block` kind; stale comments; `availability.test.ts` +3, `managedDownloadsSleep.test.ts` +1 | | `d2ff1411` | 7: `STORAGE_PASS_WAIT_MS`, `waitForStoragePass`, `settleAfterStoragePass`, `instrumentation.ts`; `bootQueuedJobs.test.ts` +5 | | `904ffc4e` | 8: `cancelReason` through `listJobs` → `fromEntry` → `JobRow` and the job page; `listJobs.test.ts` +1, `jobRows.test.ts` +1, `jobs-filters.spec.ts` +1 | | `11446fba` | 9: `SkipReporter`, one per process; `safeRevalidate.test.ts` 3 → 9 | | `fc2ce63c` | 8: a card row's reason at the end of its heading, not beside the pill | | `5b657aeb` | `plans:` this record, FACTS (three release 9 bullets amended), the editor `[Unreleased]` bullets | **Gates**, all from the worktree root. - **tsc** (`pnpm -r --no-bail --workspace-concurrency=1 exec tsc --noEmit`) clean before every code commit (`l2-tsc-1..5.log`). - **common 1,924/1,924** (1,913 + 11: availability +3, managedDownloadsSleep +1, bootQueuedJobs +5, listJobs +1, jobRows +1); **editor unit 85/85** (79 + 6, `safeRevalidate.test.ts`); **`test:scripts` 162 + 1 skip of 163**; **mcp 219/219** (`l2-units.log`, on `11446fba`). - **Editor build** `pnpm --filter editor exec next build` ok: 89 s on `11446fba` (`l2-build.log`) and 52 s on the code tip `fc2ce63c` (`l2-build-2.log`). `export/public` in the worktree: the generated paths linked from the primary, `sw.js` a plain copy. - **EDITOR e2e.** Spec list `l2-specs.txt`, 37 specs: every spec that opens `/jobs` or its jobs (`jobs jobs-filters jobs-retry jobs-channel jobs-active-order jobs-reorder jobs-batch-tasks-drain job-stream-cancel cancel queue queues dashboard widget channel-work`), the boot and runner specs (`auto-queue lane-runner ops-api backfill`), downloads and their classification (`availability availability-backfill metadata-scan-softblock metadata-scan-botcheck pacing retry-bucket rumble-sweep fetch-window undownloaded download-part-files partial-downloads-bucket title-filter pre-clean-availability maybe-missing`), sync (`sync-deep sync-break-on-existing scheduler`), a revalidating job (`truncated-check`) and `storage-locations`, all `.spec.ts`. A content grep for the prompt's five words matches 122 of 123 specs ("sync" is in "async"), so the list is by name and by what each spec drives. - Run 1 (`l2-e2e-1.log`, on `fc2ce63c`, 7 min in the queue behind L1): **213 passed, 2 failed, 13.9 min** (215 tests). `ops-api.spec.ts:447` (B1) printed the new first line live: `[safeRevalidate] no request store …; skipped revalidating /channels/slow-b, /channels, /operations/[id] (page), /cleanup, /. … 1 skip(s) since this process started; more in the next 10 min are counted and reported together.` - `channel-work.spec.ts:208`, 233 ms: `EEXIST: file already exists, mkdir '…/editor/test-transcripts/channels'` in `resetData`'s fixture copy (`helpers.ts:69`), before the test touched the page — the pre-existing fixture-reset race S3 met in `pipeline.spec.ts:164`. - `lane-runner.spec.ts:203`, 7.9 s: `apiRequestContext.get: read ECONNRESET` on a `/api/auto-queue/status` poll; the next four lane-runner tests passed. - Rerun alone, `channel-work.spec.ts:208 lane-runner.spec.ts:203 --repeat-each 3` (`l2-e2e-rerun.log`, 4m55s in the queue behind S4): **6 passed, 0 failed, 1.0 min**. Both are **pre-existing flakes**, not this slice: the EEXIST is the fixture-reset race in the helper, and the reset was a socket dropped mid-poll by the dev server. The slice's diff touches neither (`git diff --stat 5dfc9c3a HEAD -- editor/e2e/helpers.ts editor/app/api/auto-queue common/controller/autoRunner.ts common/jobs/autoQueueState.ts editor/app/channels` is empty). - The primary checkout's `export/public/sw.js` was not written (10,027 B, md5 `55cbf381…`, mtime 2026-09-25 20:47:37, before and after). - **Numbers tools: none.** **Found and left.** - ~~A soft-blocked prefetch still makes the primary attempt~~ and ~~the availability check has no rate-limit stop~~: both done in the review fixes, below. - **A hung mount can hold the boot pass's re-queues.** After the 60 s timeout, a job re-queued for a channel on a hung mount waits on the media guard's `stat`, and the re-queues after it wait with it (above). Cancels are unaffected, and re-queues are few (one at the first live boot). A bound on the guard's `stat`, or re-queues that do not wait on each other, would fix it. - **An old yt-dlp's soft-block line on a channel listing.** A flat-playlist listing that fails with the bare pre-#12958 line now classifies `rate_limit`, so `enumeratePlaylistUrls` throws `EnumerationIncompleteError` (`runYtdlp.ts:420`) and a full sweep falls back to its paged walk (one more page request). The current yt-dlp's longer line took that path before this slice (it matches `/rate-limit/`), and a flat listing never reaches the playability check that prints it. - **Manual batches have no per-video deferral.** A video that answers "try again later" every time stops every manual download-missing at itself, on every run; the auto runner defers it 6 h instead. It errs toward backing off. - **Earlier misreads cannot be found.** An availability check stores no stderr for a `deleted` result, so a soft block it read before this release is indistinguishable from a real removal. None shows in the download outcomes or scan stores (above). - **No e2e drives the soft block through a download.** The fake yt-dlp has no sentinel for the "try again later" line; the chain is pinned by the classifier tests and by `managedDownloadsSleep.test.ts`, which feeds the real line through the classifier into the batch loop. - `plans/STATE.md` still lists items 6–9 as open; it is shared with L1, so the merge updates it. - ~~An unblocked tier C check judges a suspect with no `webpage_url` on its old record~~ (pre-existing, seen while fixing): fixed in the re-read, below. - **New low: `resolveMaybeMissingState` reads an `error` probe newer than the scan as `available`** (`stateFromAvailability`'s default; pre-existing, seen while fixing). A maybe-missing video whose confirm probe failed (a 403, a network error, or the one rate-limited probe of a blocked run) stops being flagged. It is display-only: it feeds the maybe-missing state in `buildIndex.ts` (~:1180), i.e. the published site's presence badge. No deletion path reads it; the clean gate uses `resolveEffectiveAvailability`. The fix is one line (`error` → `maybe_missing`), but it changes published presence semantics and the maybe-missing count, so it needs its own export check: not in this slice. Before this slice the soft block published a false "deleted", which was worse. **Review fixes (review verdict SHIP AFTER FIXES, `l2-review.md`).** - **Item 7's wording (should-fix).** The comment, the timeout line and this record promised that a job re-queued for a channel the storage pass had not reached is refused at once. True for an unmounted drive; not for a hung mount, where the media guard's own `stat` hangs too and the sequential re-queues behind it stay `queued`. Corrected in all three; listed under found and left. No behaviour change. - **A rate-limited prefetch ends the download (question a).** In `runManagedDownload`, after the prefetch's auth retry: when the last prefetch attempt classifies `rate_limit`, one log line (`Metadata prefetch for was rate-limited by the source; not attempting the download …`) and the record `failed` / `rate_limit` with only the prefetch attempt(s). The sidecar and availability-history tail is one helper, `writeOutcome`, which both exits call. Every other failure class keeps today's flow. The batch's cooldown and abort, and the runner's deferral, follow from the record unchanged. - **The availability check stops on a rate limit (question b), with the data-loss guard in the same commit.** - `runAvailabilityCheck`: the first probe that classifies `rate_limit` records its own `error` and sets `blocked`. No further probe starts; the rest count as `skipped`, and a `STOPPED: …` line says how many. After the pass the platform cooldown is recorded once, best-effort, with `recordDownloadBackoff(detectPlatform(config?.url ?? url) ?? "unknown", paths)`, the key the batch and the Sync gate use (`onPlatformBackoff` is the test seam). The result gains `blocked`, `blockMessage` and `probedIds`. - **`verifyBeforeClean`: every tier C suspect the check did not probe is `unverified`.** Judged on its old `availability.json` (a months-old `public`), it would have been cleared for an irreversible delete. As first fixed (`b8053655`) this applied only to a BLOCKED check. The re-read (`52bb00ef`) dropped that condition, so a suspect an unblocked check skipped for having no `webpage_url` is unverified too. That closes a pre-existing gap. The review's read-only scan found no such video on the live corpus (79,700 dirs, 1,186 clean candidates), so nothing changes today; such a video is simply never cleaned until it has a `webpage_url`. A suspect every probe reached is judged exactly as before. - The other callers are unaffected, as the review said. `checkKeptDeleted` only pins, and an unprobed id keeps its old verdict (pinning is the safe direction). The full-sweep confirm leaves an unprobed id `maybe_missing`, because its probe time is older than the scan's. - **Two found-and-left lines** (above): an old yt-dlp's soft-block line on a flat listing, and manual batches stopping at a persistently soft-blocked video. | sha | what | |---|---| | `61c03ac1` | (a) the rate-limited prefetch exit, `writeOutcome`; `prefetchRateLimit.test.ts` (4) | | `24e0f7d5` | item 7's comment and timeout line say what a hung mount does | | `b8053655` | (b) the availability check's stop + cooldown + `probedIds`; `verifyBeforeClean` leaves unprobed suspects unverified; `checkAvailability.test.ts` (3), `verifyBeforeClean.test.ts` +2 | | `89218c38` | `plans:` this paragraph, the item 7 record text, found and left, FACTS, the `[Unreleased]` bullets | | `52bb00ef` | (re-read) every unprobed tier C suspect is unverified, blocked or not; `verifyBeforeClean.test.ts` +1 (a no-URL suspect in an unblocked check) | | _this_ | `plans:` the re-read fix and the `resolveMaybeMissingState` low in this record, FACTS | **Tests, and the proof they bite.** - `prefetchRateLimit.test.ts` drives the real `downloadOneManaged` against a temp yt-dlp script that counts its spawns. A soft block gives 1 spawn, 1 attempt and a `rate_limit` sidecar, and a 429 is the same. A 403 still reaches the primary (2 spawns, `network`), and so does a removed video (2, `per_video`). With the exit disabled, the soft-block and 429 cases fail. - `checkAvailability.test.ts` covers four probeable videos: - a soft block gives 1 spawn, 3 skipped, one `error` written and one youtube cooldown; - a 429 and the bot check behave the same; - a 403 and a removed video probe all four, with no cooldown. - `verifyBeforeClean.test.ts` runs through the real quick check and tier C: - a blocked confirmation leaves all 3 suspects `unverified`, although two carry a stale `public`; the listed video stays cleanable, and the cooldown lands in the state file; - an unblocked one probes all 3 and judges `public` / `deleted` / 403 as before; - with the guard disabled, the blocked case fails. **Re-gate on `b8053655`:** - tsc clean before each fix commit (`l2-tsc-6..8.log`). - Unit tests (`l2-units-2.log`): common **1,933/1,933** (1,924 + 9), editor unit **85/85**, `test:scripts` **162 + 1 skip of 163**, mcp **219/219**. - Editor `next build` ok, 43 s (`l2-build-3.log`). - EDITOR e2e (`l2-e2e-2.log`): the 37 specs plus 9 cleanup / availability specs (`cleanup-actionable cleanup-holds cleanup-page do-not-clean shard cookies-mode whisper channels-actions auto-subs-replace`; `pre-clean-availability`, `availability`, `availability-backfill` and `maybe-missing` were already in the list). **256 passed, 0 failed, 16.2 min**, no queue wait. - The primary's `export/public/sw.js` was untouched again. **Re-read fix (`l2-review.md`, "Re-read of fixes": no must-fix, one should-fix), on `52bb00ef`.** - The fix is `52bb00ef`, above. - The new `verifyBeforeClean.test.ts` case puts a no-URL suspect with a stale `public` into an unblocked check: - only the other suspect is probed; - the no-URL one comes back `unverified` and excluded; - the probed `public` suspect and the listed video stay cleanable; - no cooldown is recorded. With the old blocked-only condition it fails; the blocked and unblocked cases still pass. - tsc clean (`l2-tsc-9.log`). - common **1,934/1,934** (+1, `l2-units-3.log`). - EDITOR e2e `cleanup-actionable cleanup-holds cleanup-page do-not-clean pre-clean-availability` (`l2-e2e-3.log`): **12 passed, 0 failed, 1.1 min** (9 s in the queue). `pre-clean-availability` was added to the four named specs because it drives all three tiers of the gate end to end. ### Slice L1, as shipped — hub lows (2026-09-26) Branch `r10/hub-lows` off `main` `5dfc9c3a`, worktree `/home/user/Projects/r10-hub-lows` (port block #6). All five items are done. Item 4 follows the homepage: the code gave no reason to keep alphabetical (below). 1. **`retry: false` on the federated subs manifest** (superseded by review fix 4 below: a 404 is an empty manifest, and a real error is retried once) (`common/components/SearchDataContext.tsx`, the `subsQueries` of `MultiSiteDataProvider`). Readiness waits for the subs query to settle. A member with no live chat answers 404, and the client default (`retry: 1`, `QueryProvider.tsx`) held its chip at `loading` for one more second. The single-site `useSubsManifest("")` shares the query key shape but never an origin the hub uses, so it is unchanged. **Cost, accepted in the C2 re-read:** a transient network error on a member's subs manifest is also not retried. Its chip reads `ready` without that member's live chat, and no Retry is offered, because the chip is not `failed`. 2. **`/ask` honours the scope chips.** `AskHub` builds its list with `useFederatedSites()`, the same hook `HubHome` uses, so an archive switched off on the front page is not fetched on `/ask`. It is still not `progressive`: the chat waits for every archive in scope to settle. 3. **One colour per archive, on every surface.** `officialInstances(members, summary)` (`common/lib/hubSummary.ts`, pure) resolves each official instance's colour: the site's own accent, else the summary's, else `seriesColor()` at its index in the summary, the colour of its homepage card and chart layer. It replaces `ArchiveShelf`'s private fallback (C1). A member the summary does not name takes the colours after the summary's, so it cannot share a colour with a named instance. Before, an unnamed member took its card index, which could collide. `useHubSites()` (`export/app/components/hub/useHubSites.ts`) is the one list every hub surface reads, each official entry wearing its resolved colour: the shelf's cards, the scope chips' dots, the result cards' left edge (`accentOf`), and the channel groups' dots (`FiltersPanel`, `HubOfflineManager`). An added archive with no accent still has no stripe in the results. Its shelf card keeps `var(--brand)`. 4. **One order.** `officialInstances` also orders the official instances by the summary's `sites`, the homepage's order (transcripts, most first: `homepageSummary.ts:160`). A member the summary does not name follows, in `hub-sites.json` order, and with no summary that order stands. The cards, the chips, the filter panel's groups and `/ask` all follow it. **Why not alphabetical:** nothing in the code depends on `hub-sites.json`'s order. The channel list sorts by origin itself, `corpus.json`/`llms.txt` keep their own (compose-time, unchanged), and the merge sorts records by date. The one cost is that the order moves when two instances' transcript counts cross. The homepage already does that, and the hub now does it on the same build. **No reorder flash:** `useHubSites` lists the official instances only once `/hub-summary.json` has settled (`useHubSummary` now returns `{summary, settled}`), whether it was found, missing or unreadable. Both files are small, same-origin and requested together (the hub service worker passes both straight through), so the wait is the gap between two requests already in flight. `HubStats` now counts the provider's list (`federation.sites`) rather than the registry. It therefore never reads "Searching 0 archives" while that list waits. 5. **`export/playwright.config.ts` unlinks `public/sw.js` before copying `site-sw.js`** (`fs.rmSync(SW_DEST, {force: true})`). In a worktree that path is a link into the primary, and `copyFileSync` writes through a link. Proof below. | sha | what | |---|---| | `1f852979` | `export: playwright.config.ts` unlinks `public/sw.js` before copying the site worker (item 5) | | `6fa6bd3b` | `common: officialInstances`: the summary's order and one colour each, plus 4 unit tests (items 3–4) | | `cf5455c0` | `hub:` `retry: false` on the federated subs manifest; e2e counts one subs request at `ready` (item 1) | | `90ec10a1` | `hub:` `useHubSites` / `useFederatedSites`; `ArchiveShelf`, `HubHome`, `HubStats`, `AskHub` read it; `useHubSummary` `settled`; e2e for order + colour (items 3–4) | | `21be8700` | `hub: /ask` honours the scope chips; e2e (item 2) | | `86d686fb` | `export: [Unreleased]` (a new section above `[0.9.0]`) | | `a9d31063` | `plans:` this record | **Gates** - **tsc:** clean before every code commit. Five runs, 37–91 s (`l1-tsc-1..5.log`). `cf5455c0` was staged as a subset of a tree that had passed tsc (`l1-tsc-3`), with no type dependency on the unstaged rest. - **Unit and script tests** on `86d686fb`: common **1,917/1,917** (1,913 + 4, all `officialInstances`), editor unit **79/79**, `test:scripts` **162 + 1 skip**, mcp **219/219**. - **Builds** on `86d686fb` (`l1-gates.log`), with `export/public` seeded under sh (no dangling links) and `homepage/public` data copied (60 MB): - editor ok (44 s); - export site ok (30 s), `data-accent="signal"`; - export hub ok (31 s), `dark data-base="dark"`, `out/ask/index.html` present; - homepage ok (22 s). - **e2e on `86d686fb`** (`l1-e2e.log`): - export full **204 passed**, 9.6 min; - `e2e:hub` **22 passed** (19 + the 3 new tests), 46.1 s, after 18.4 min in the queue behind L2; - `e2e:2origin` (`TWO_ORIGIN_REBUILD=1`; the seven composed entries + `sw.js` swapped for copies, relinked after) **3 passed**, 35.7 s; - homepage full **27 passed**, 42.6 s. - During development: `e2e:hub` 21/21 (56.8 s, with the item-1 and items-3–4 tests). Then `federated-search` + `ask` + `ask-grounding` went 9 passed, 1 failed, and 10/10 after a fix to the new `/ask` test's own wait. `AskChat`'s composer reads ready for the instant before the registry loads, so the test now waits for Origin A's fetch first (below). - **Red on the base source** (`l1-redgreen.log`). The three new tests were run with the six source files checked out at `5dfc9c3a` and the specs kept. Each failed for the reason it exists: - the subs test made **2** subs requests by `ready` (expected 1); - the order test's first card read "Origin A" (expected "Origin B"), so its colour assertions never ran; - `/ask` made **7** requests to the switched-off Origin B (expected none). The source was restored and the tree left clean. - **Item 5, proved.** Before the export run the worktree's `export/public/sw.js` was a LINK to the primary's. The config loaded, and the worktree's became a plain 11,172 B file (md5 = `site-sw.js`). The primary's `export/public/sw.js` read **10,027 B, mtime 2026-09-25 20:47:37.183119759, md5 `55cbf381…`** before the run, during it (02:38), after it, and after every later run. The other seven composed files kept their sizes and mtimes throughout (`l1-primary-public-before.txt`, `l1-gates.log`, `l1-e2e.log`). - Numbers tools: none. No `settings.json`, `site.json` or `config.json` key changed. `hub-sites.json` and `hub-summary.json` are unchanged on disk: the order is applied in the browser. **Found and left** - **The `/ask` composer reads ready before the hub's list loads** (pre-existing; CLOSED by review fix 1 below: an empty scope is never ready on `/ask`). With no archives listed yet, the provider has nothing in scope, so it counts as settled and `summariesReady` is true for that instant. A question sent then would ground in nothing. `ask.spec` relies on the empty-hub case being ready. The window used to end when `hub-sites.json` arrived. It now ends when both it and `hub-summary.json` have arrived: same-origin, and requested together. A fix would be for `AskHub` to count as ready only once the registry's built-ins have loaded. That needs a "loaded" signal from `siteRegistry.ts`, which does not expose one. - **`/ask` does not show which archives are in scope.** The front page has the chips; `/ask` silently follows them. A visitor who switched an archive off weeks ago gets answers from the rest. A one-line "Searching N of M archives — change on the front page" would need a copy ruling. - **After the rollout the hub's cards and the homepage's cards differ in colour.** The hub prefers a site's own accent (C1, kept); the homepage's `ArchiveCards` deliberately draws only `seriesColor(i)` so a card matches its chart layer. The two sites now list the instances in the same order. - **FACTS is stale after merge:** "Replace `export/public/sw.js` with a copy before any export e2e in a worktree" (Release 9 facts, "Worktree e2e can write INTO the primary") no longer holds for `sw.js`. Review fix 2 below retires the compose-output swap too. Not edited here (a shared file). `[Unreleased]` bullets: `export/CHANGELOG.md` (three: order and colour, `/ask` scope, subs). `editor/CHANGELOG.md` and `homepage/CHANGELOG.md` are unchanged, because neither app changed. The item-5 harness fix is not in the public changelog. **Review fixes** (review SHIP AFTER FIXES, `l1-review.md`; four findings, all fixed on `r10/hub-lows`). 1. **Must-fix: `/ask` with every archive switched off** (`efe6e48f`). - **The failure.** With L1's scope, an empty in-scope list counted as settled, so the composer enabled and a question went out over zero records. - **Readiness.** `MultiSiteDataProvider` now counts an empty scope as NOT ready unless it is `progressive`. `/ask` is not progressive, so it stays disabled. That also closes the instant before the hub's list arrives (the early-ready window from "Found and left", which no longer applies). The progressive front page still settles on an empty scope. Its results read "No videos match" rather than "loading index…" forever, and its chips and "Searching 0 archives" say why. - **The line.** `AskChat` shows one line in the composer when the hub has archives and every one is off: `NO_ARCHIVES_IN_SCOPE`, `role="status"`, `data-testid="ask-blocked"`. It replaces the placeholder and the key hint. The box and the Ask button are disabled. - **The copy** is a single exported constant in `export/app/ask/hubScopeCopy.ts`, for the operator. The draft is now "No archives selected. Choose some on the [hub's front page] to ask." (re-read, `0fc63ed7`). The first draft said "Turn one on above", but `/ask` shows no chips. The [bracketed] words render as a link to `/`, where the chips are; dropping the brackets drops the link. - **A hub with no archives at all** now reads "Loading transcripts…" on `/ask`, instead of being ready over nothing. `ask.spec` accepts either. 2. **Should-fix: the compose scripts wrote through the worktree links** (`1203f4a6`). - **The helpers.** `common/bin/_publicFile.ts`: `writePublicFile` and `copyPublicFile` `rm` the path first (`fs.rm` uses lstat, so only a link is removed, never its target). `ownDir` replaces a linked directory with an empty real one. - **compose-hub** routes every write through them. - **compose-site** routes through them every top-level file and the per-site subs/posts/digests manifests, and `reconcileChannelTree` owns its tree first. The paths that already removed first (summaries/stats `replaceDir`, archives, `sw.js`, chart-templates) are unchanged. - **Real builds are unchanged.** Where the paths are real (the primary checkout's `export/public`, which holds no links, and the docker per-site `/site/public` dirs), the result is what a plain write gave. - **Tests.** `_publicFile` (4). `compose-hub`: a worktree compose leaves all seven of the primary's files' content and mtime as they were. This one was red on the old `compose-hub`. `compose-site`: a linked tree is owned, and the primary's is untouched. - **Proof:** below. 3. **Nit** (`609a7fe1`): `useHubSummary` runs with `networkMode: "always"`, so a first mount after an `offline` event settles as "no summary" instead of pausing. 4. **Nit: the subs query** (`78e837f1`, replacing `cf5455c0`'s `retry: false`; its failure rule was corrected in the re-read, `d0fa13ae`). - **A 404** resolves to an empty manifest at once, the way posts does. It is one request. - **Any other error** keeps one retry (explicit on this query). If it still errors, the query counts as SETTLED: the archive is ready, its videos are searched, and only its live chat is missing. This is C2's rule. `78e837f1` had instead made such an archive `failed`, which the re-read reverted (below). - **Why a 404 is readable.** A member's `/subs/*` carries CORS on 404s too: checked live, `https://jeralyzer.pages.dev/subs/.json` → `404` + `access-control-allow-origin: *`. So a missing file reads as a 404, not as a network error. `serve`, which self-hosted docker archives use, sends a 404 with no CORS header, and that is the case the settled rule covers. | sha | what | |---|---| | `78e837f1` | `hub:` subs: a 404 is an empty manifest; a real error is retried once, then failed its archive (reverted by `d0fa13ae`); e2e (finding 4) | | `efe6e48f` | `hub: /ask` with every archive off is not ready and says why (`NO_ARCHIVES_IN_SCOPE`); an empty scope is never ready when not progressive; e2e (finding 1) | | `609a7fe1` | `hub: useHubSummary` `networkMode: "always"` (finding 3) | | `1203f4a6` | `common:` `_publicFile.ts`; compose-hub and compose-site never write through a link; 6 unit tests (finding 2) | | `323c878f` | `export: [Unreleased]` — the two reader-visible fixes | | `5304ce2d` | `plans:` these review fixes | **Gates on `323c878f`** - **tsc:** clean before every code commit (`l1-tsc-6..8`). `78e837f1` was staged as a subset of the tsc-green `l1-tsc-7` tree. - **Unit tests:** - common **1,923** (1,917 + 6); - editor unit **79**; - `test:scripts` **162 + 1 skip**; - mcp **219**. - **Builds** (`l1-fix-gates.log`), all ok: - editor (53 s); - export site (43 s); - export hub (33 s); - homepage (20 s). - **e2e** (`l1-fix-e2e.log`): - export full **204 passed**, 7.1 min (after 9.0 min in the queue behind L2); - `e2e:hub` **24 passed** (22 + the 2 new tests), 1.4 min; - `e2e:2origin` (`TWO_ORIGIN_REBUILD=1`, links in place, **no swap**) **3 passed**, 34.0 s; - homepage full **27 passed**, 44.8 s. - **During development:** the three touched hub specs went 11 passed, 1 failed. The failure was the new `/ask` test waiting for an answer the fixture member cannot produce: it serves no transcript tree, so the chat's own search never finishes. The test now asserts the question reaches the provider. After that, 1/1. - **Red on the pre-fix source** (`l1-fix-e2e-red.log`, with the four `/ask` and provider files at `a9d31063`): the two new tests fail for the reason each exists. - The subs-500 chip read `ready` (expected `failed`). - With every chip off, `/ask` showed no blocked line. The source was restored and the tree left clean. - **Proof of finding 2:** the export and hub runs left the seven composed entries as LINKS into the primary (`sw.js` was already the worktree's own, from item 5). - **Before**, primary `export/public` (size, mtime, md5): | file | size | mtime | md5 | |---|---|---|---| | `sw.js` | 10,027 | 20:47:37.183119759 | `55cbf381…` | | `hub-summary.json` | 1,441 | 20:48:07.499176932 | `7d8a101e…` | | `hub-sites.json` | 579 | 20:47:37.173642710 | `28b9893c…` | | `corpus.json` | 1,535 | 20:47:37.177642649 | `54b64280…` | | `llms.txt` | 1,031 | 20:47:37.177642649 | `87255fa7…` | | `robots.txt` | 130 | 20:47:37.178642634 | `60217070…` | | `sitemap.xml` | 364 | 18:08:42.340736263 | `76ffaa3c…` | | `_headers` | 459 | 20:47:37.178642634 | `a5f88f82…` | All mtimes are 2026-09-25 (`l1-fix-primary-before.txt`). - `e2e:2origin` ran `build:hub` through those links. - **After, the worktree** holds its own plain files: `hub-sites.json` 2 B (`[]`), `corpus.json` 468 B, `llms.txt` 494 B, `robots.txt` 76 B, `_headers` 459 B, `sw.js` 11,005 B. `hub-summary.json`'s link was removed (no index). `sitemap.xml`, which compose-hub does not write, is still a link. - **After, the primary's eight files** are byte-identical, with the same sizes and mtimes (`l1-fix-primary-after-2origin.txt`, `diff` empty). They were identical again after the homepage run (`l1-fix-primary-after.txt`). **Retired at merge (for FACTS; not edited here):** both worktree workarounds become unnecessary. - "Replace `export/public/sw.js` with a copy before any export e2e in a worktree" (Release 9 facts): `playwright.config.ts` unlinks it (item 5). - "Swap the compose outputs for copies before `e2e:2origin` and relink after" (Brand facts, "Worktree e2e and builds write through the `export/public` links"): compose-hub and compose-site unlink before writing. The seed (per-path links from the primary) stays as it is. A worktree run now replaces a link with its own file, and the next seed relinks it. **Re-read of the fixes** (`l1-review.md`, "## Re-read of fixes"; SHIP AFTER FIXES). - **R1, must-fix: a subs failure must never fail an archive** (`d0fa13ae`). After `78e837f1`, a non-404 subs error, after its retry, made the archive `failed`. Its already-loaded videos then left the search and `/ask`, and the line said it "did not answer". This was reachable on a self-hosted archive with no subs manifest, where `serve` sends a 404 without CORS. C2's rule is restored: a subs query that still errors after its retry counts as settled, so the archive is `ready` without its live chat. The 404 → empty manifest and the one retry are kept. The e2e is inverted: a 500 gives 2 subs requests, the chip reads `ready` with no Retry, Origin B's result is present, and there is no status line. The changelog sentence now says what a reader sees: the archive is searched without its live chat, and its chip reads ready. - **The copy** (`0fc63ed7`). The new draft, with its front-page link, is described under finding 1 above. `copyWithLink` / `copyText` split and flatten the one constant. The e2e takes the link text from the constant and checks that it points to `/`. - **R2** (`ownDir` is safe) and **R4** (the non-progressive rule is confined to `/ask`) were verified by the re-read. No change. | sha | what | |---|---| | `d0fa13ae` | `hub:` a subs manifest that still fails after its retry never fails its archive (C2's settled rule); e2e inverted; changelog sentence | | `0fc63ed7` | `hub: NO_ARCHIVES_IN_SCOPE` draft "No archives selected. Choose some on the [hub's front page] to ask.", the phrase a link to `/` | | _this_ | `plans:` this re-read record | **Gates on `0fc63ed7`** (`l1-fix2.log`) - **tsc:** clean (`l1-tsc-9`, on this tree; `d0fa13ae` is a subset of it). - **Unit tests:** common **1,923**, editor unit **79**. - **Build:** export hub ok (26 s), `out/ask/index.html` present. - **e2e:** - `e2e:hub` **24 passed**, 51.1 s; - export full **204 passed**, 6.7 min, `sw.js` a link at the start; - `e2e:2origin` (`TWO_ORIGIN_REBUILD=1`, the seven composed entries links in place, no swap) **3 passed**, 36.3 s. - **The primary's eight `export/public` files** kept the size, mtime and md5 in the table above through all three runs (`l1-fix2-primary-before.txt` vs `-after.txt`, `diff` empty). The worktree again held its own `hub-sites.json` (2 B), `corpus.json`, `llms.txt`, `robots.txt` and `_headers` after 2origin. **Found and left (new lows, both need copy):** - **`/ask` waits forever on a hub with no archives.** When `hub-sites.json` is `[]` (a fresh self-hosted hub) or failed (`loadBuiltins` never retries), and the reader has added none, `/ask` reads "Loading transcripts…" forever. The fix is a "built-ins loaded" flag from `siteRegistry.ts` plus a blocked line for "this hub has no archives", whose copy goes to the operator. The production hub (five members) never reaches this state online. - **Show a member's missing live chat.** A subs manifest that settled in error leaves its archive `ready` with no sign that its live chat is missing. A chip note with a subs-only Retry would show it. ### Integration (S4 + L1 + L2), as merged (2026-09-26) The operator asked for S4, L1 and L2 on `main` now, before the brand rollout, and will re-cut the editor and export release notes before deploying. Each slice was reviewed to SHIP on its own branch; they had never run together. The parent merged them in the planned order, and this pass gated the combined tree, wrote the records and updated the runbook. Worktree `/home/user/Projects/brand-found-line`, branch `brand/found-line` fast-forwarded to `main` `5c0a6ef9`, port block #1. **No code changed at integration**: every gate below ran on `5c0a6ef9`, and the commits after it are `plans:` only. During the pass `main` gained `4ac32a2e` (`plans/mcp-fetch-clip.md`, plans-only), and the integration commits were rebased onto it so `main` can fast-forward. | merge | branch → tip | review | on `main` | |---|---|---|---| | **S4** Archilyzer Media | `brand/media` → `333d2826` | SHIP AFTER FIXES → re-read SHIP | `dcb04f61` | | **L2** runner lows | `r10/runner-lows` → `14e96739` | SHIP AFTER FIXES → re-read fix `52bb00ef` | `41ddc382` | | **L1** hub lows | `r10/hub-lows` → `b6b47ec1` | SHIP AFTER FIXES, twice (re-read `d0fa13ae`, `0fc63ed7`) | `5c0a6ef9` | **Conflicts resolved at merge** (`git show --remerge-diff`): `plans/release-10.md` in all three (the record sections; every section kept, S4's line, then L2's record, then L1's), and one `editor/CHANGELOG.md` `[Unreleased]` join in L2's merge (S4's bullet and L2's four in the one new section). No code file conflicted. **Changelogs, checked as one set.** `editor/CHANGELOG.md` and `export/CHANGELOG.md` each have exactly one `[Unreleased]`, and everything from `[0.9.0]` down is byte-identical to the 0.9.0 cut (`31798769`; md5 of the released part `aeb7fd45…` editor, `ae97cb39…` export). The editor's five bullets (S4's umtool brand; L2's soft block, boot wait, `cancelReason`, safeRevalidate count) and export's three (L1's order and colour, `/ask` scope, subs) neither repeat nor contradict each other or the code as merged (the subs bullet states the final rule: a failure after its one retry leaves the archive searched without its live chat). No rewrite was needed. `homepage/CHANGELOG.md` is unchanged by all three. **Gates**, all from the worktree root on `5c0a6ef9` (`$T/i10-gates.log`, `$T/i10-e2e.log`). - **tsc** (`pnpm -r --no-bail --workspace-concurrency=1 exec tsc --noEmit`): clean, 0 errors, 80 s. - **Unit and script tests:** - common **1,954/1,954** (1,913 + S4's 10 + L1's 10 + L2's 21), 59 s; the glyph-parity test ran, not skipped (fontTools 4.65.0 installed); - editor unit **85/85** (79 + L2's 6); - `test:scripts` **173 passed + 1 skip of 174** (162 + S4's 11 `brand.test.mjs`; the report-to-video tests are in this script; the skip is the `LIVE=1` network test); - mcp **219/219**; - `file-schemas-docs.ts --check` clean. - **Builds**, all ok, `export/public` seeded per path from the primary (links, no copies), `homepage/public` copied (60 MB): - editor 64 s (`BUILD_ID` `OjVWgLuZA1ucPntDKDx8C`); - export site 41 s: ``, theme-color `#f3f6f7` / `#0c0a08`; - export hub 44 s: `… dark" data-base="dark" data-accent="signal"`, `out/ask/index.html` present; - homepage 20 s: `dark data-base="dark" data-accent="signal"`; - umtool 29 s (`pnpm --filter umtool run build`, `BUILD_ID` `BktSP58PkTab_aLIBJ1Vc`). - Every export/homepage build: `out/icons` = the seven files; PNG magic `89504e470d0a1a0a` and IHDR 180/192/32/512/512 (apple-touch, 192, 32, 512, maskable-512); `favicon.ico` `00 00 01 00 03 00` (1,804 B site, 1,832 B hub/homepage); the site's `icon.svg` lit Signal `#5fa8a0` on `#0c0a08`, the hub's and homepage's the parent mark `#151b20 #3f4c56 #e7edf1`. - **e2e**, one detached sequence (13:04 → 14:19), every run through the machine-wide queue: | suite | passed | failed | skipped | time | |---|---|---|---|---| | export full | **204** | 0 | 0 | 9.7 min | | `e2e:hub` | **24** | 0 | 0 | 1.1 min | | `e2e:2origin` (`TWO_ORIGIN_REBUILD=1`, links in place, no swap) | **3** | 0 | 0 | 1.4 min (2.2 min with `build:hub`) | | homepage full | **27** | 0 | 0 | 49.7 s | | umtool full (`SONG_DIR=~/reports/quartering-uh-song/data`) | **175** | 2 | 45 | 5.8 min | | **editor full** (`pnpm e2e`, 652 tests) | **634** | 6 | 12 | 54.6 min | The 45 umtool skips are the song-data capabilities this machine lacks (FACTS "The umtool suite skips instead of going red…"); S4's full run had the same 175 / 2 / 45. - **Failures, classified** (each re-run alone ×3: umtool `$T/i10-rerun.log`, editor `$T/i10-rerun2.log`): - **umtool `mix.spec.ts:166` and `:201`: known flake, not the integration.** The pair FACTS records as failing in every full umtool run on `main` (`FACTS.md:5013`, "`mix.spec.ts` IS ORDER-DEPENDENT"): an earlier spec leaves a clip window in the fixture's `channels/testchan/data/vid1/clips/`, and the row links to it (`…/clips/0.00-14.00.mp4`) instead of `/out/clips-raw/vid1_0.00-9.00.mp4`. S4's full run failed on exactly this pair. Alone, `--repeat-each 3`: **6 passed**, 24.4 s; the whole `mix.spec.ts` alone: **12 passed**, 23.0 s. None of the three slices touches `umtool/app/mix`, `umtool/lib/projects/report.mjs`, `mix.spec.ts` or the fixture: in umtool's app and lib, S4 changed only `bin/umtool.mjs` (`--brand`), `components/NewProjectMenu.tsx`, `lib/projects/{kinds,scaffold}.mjs`, `scaffold.ts` and `lib/tools.mjs`'s doctor label (`git diff --stat 386ac995 5c0a6ef9 -- umtool/app umtool/lib umtool/components umtool/bin`). - **Editor: six failures, all flakes under machine load; none is the integration.** Each alone, `--repeat-each 3`, straight after the suite (`$T/i10-e2e-editor-x3.log`): **18 passed, 0 failed, 1.8 min**. The suite ran while the live :3001 editor was busy with its own auto-queue work (load average 10–13 on 8 cores; 7.4 during the re-runs), and took 54.6 min against S3's 38.2. The six: - `queues.spec.ts:144` (281 ms): `EEXIST: file already exists, mkdir '…/test-transcripts/channels/slow-b'` in the fixture setup, before the test touched the page. This is the fixture-reset race S3 met in `pipeline.spec.ts:164` and L2 in `channel-work.spec.ts:208`. - `pulse.spec.ts:18` (3.6 s): the idle `/api/pulse` averaged 499.2 ms a call against its 250 ms tripwire. The spec calls that budget "a tripwire … not a benchmark, and it runs on a shared machine". The idle path (in-memory state plus two `stat`s) is untouched by all three slices; the one `listJobs` change adds a field only to cancelled metas, and pulse does not list jobs. - `sync-deep.spec.ts:256` (ENOENT reading `viddeleted1/availability.json`) and `:442` (`lastFullSweepAt` undefined). Both read something the sync writes AFTER the `lastSyncedAt` stamp their `runSync` helper waits on: `runYtdlp.ts:1853` `touchLastSync`, then `:1858` `touchLastFullSweep` and `:1859` `safeBackfillAvailability`. That is a pre-existing race in the spec's wait, which load widened. The merges change `runYtdlp.ts` by one comment (`:904-909`) and `backfillAvailability.ts` not at all. - `jobs-batch-tasks-drain.spec.ts:54` (24.6 s): the task row's elapsed timer read `0:06` and did not pass it within 10 s. The fake whisper's slow task lives 7 s (`fake-whisper.mjs`, ten 700 ms lines), so a bar that renders late under load leaves about a second before the task ends. L2's `JobRow.tsx` change draws only on `cancelled` rows (`cancelReasonOf`), and the running task's timer (`JobProgressBars.tsx`) is unchanged. - `diarization.spec.ts:185` (1.3 min): the channel snapshot's `transcribedWithAudio` bucket read `[]` for the whole 60 s poll (expected `["vidA"]`). The same poll failed in an earlier full run (the storage-locations slice 4 worktree, job `4cc6ed24`: `["vidC"]`, left by the previous test's batch) and passed on its re-run. `channelSnapshot.ts` is untouched by the merges, and the file's other four tests passed, including the backfill test that drives the same cleanup. - **The `export/public` links stayed in place, and the primary was never written.** The rules' per-path seed (`$T/i10-seed-public.sh`): every gitignored entry of the primary's `export/public` linked into the worktree, and no `sw.js` or compose-output copies (the workarounds L1 retired, FACTS updated). - The primary's `export/public` had changed since L1's proof: the live editor's Jeralyzer build-deploy (below) re-composed it at 11:03 as a SITE, so it holds `site.json` and no hub `sw.js`, `hub-sites.json` or `hub-summary.json`. Of L1's eight files, five exist, plus `site.json`. The worktree's stale links to the three absent ones were dropped before the seed, so none dangled (the rules' check before every export build). - With no primary `sw.js` to link, the worktree's `sw.js` was linked to a sentinel in the job's scratch (`$T/i10-sentinel/sw.js`), so a write-through by the export config would change it. - The snapshot (`$T/i10-snap.sh`): every entry of the primary's `export/public` (614 entries, 543 files, 2.9 GB) by type, size and mtime, and the md5 of every file, plus the sentinel's md5. It was taken before the gates, after them, before the e2e, after the export run, after `e2e:hub`, after `e2e:2origin`, and after the editor suite: **identical every time** (tree md5 `2f7f3bb9be9d`, content md5 `e3661fb98468`, sentinel `fc1bd566…`), and the primary still had no `sw.js`, `hub-sites.json` or `hub-summary.json`. The five named files throughout: | file | size | mtime (2026-09-26) | md5 | |---|---|---|---| | `corpus.json` | 13,702 | 11:03:23.547761504 | `f89e603b…` | | `llms.txt` | 4,245 | 11:03:23.574761030 | `5b6be721…` | | `robots.txt` | 125 | 11:03:23.574761030 | `17b7cc13…` | | `sitemap.xml` | 352 | 11:03:23.575761012 | `4bd0cd6b…` | | `_headers` | 736 | 11:03:23.513762100 | `302b4216…` | | `site.json` | 5,858 | 11:03:23.541761609 | `9394b81b…` | | `sw.js`, `hub-sites.json`, `hub-summary.json` | absent | | | - **What the runs did in the worktree:** the export suite replaced the `sw.js` LINK with a plain 11,172 B file whose md5 equals `service-worker/site-sw.js` (`15b4689b…`), and the sentinel kept its md5 — `playwright.config.ts:54`'s `rmSync` removed the link. `e2e:2origin`'s `build:hub` replaced the `corpus.json`, `llms.txt`, `robots.txt` and `_headers` LINKS with the worktree's own plain files (468 / 494 / 76 / 459 B), wrote a plain `hub-sites.json` (2 B, `[]`) where none existed, removed the `site.json` link and left `sitemap.xml` (which it does not write) a link. The next seed relinked them for the homepage and later suites. - **The live processes were never touched:** :3001 still serves `BUILD_ID` `P0VMdKX7gbdsaiS5GvorF` and :3050 `a9YAe_dwhuM6DiCPzIwMD`; nothing was deployed, pushed, restarted or written under `transcripts/`. - Numbers tools: none. **Found during the pass.** - **Jeralyzer is already on the brand, in Signal.** Job `01M3F36N7SCKGC5JV27EBDFYPN` (`build-deploy`, queue `deploy`, no agent recorded) ran on the live :3001 editor 2026-09-26 10:51–11:06. It built Jeralyzer from the primary's working tree, then `main` @ `386ac995` (the brand plus the 0.9.0 cut, before S4/L1/L2), and deployed it to production (`https://35593886.jeralyzer.pages.dev`). The old editor runs the on-disk build scripts, so the site got the brand, but its `site.json` has no `accent` (the pre-brand editor cannot set one), so it wears Signal. `jeralyzer.pages.dev` serves `data-accent="signal"`; the other four sites, the hub and the homepage are pre-brand. The runbook now says so. The sites step (the runbook's 4, "Rollout" 5 below) rebuilds Jeralyzer in Brass, and its readers' stored themes have already migrated. - **umtool is not "untouched" any more.** The live :3050 already spawns `main`'s report-to-video scripts from disk. That is safe (a render that does not opt in is byte-identical), and the form's brand choice needs a rebuild. The runbook adds an optional step for it (`r10-umtool.sh`). **Runbook** (`~/reports/release-10/make-runbook.py`, regenerated `RUNBOOK.html`): - `FINAL` is this pass's tip, and every script's ancestor guard checks it. - It says what the rollout contains: the brand, then S4, L1 and L2, in plain words. - Step 0 is a **re-cut**: both `[Unreleased]` sections are shown, since export's is public. The editor cut, the hand commit of both files and the untracked-file caveat are unchanged. - The new **step 1b** is an OPTIONAL `r10-umtool.sh`: fail-stop, with the ancestor guard, and it refuses while a umtool job runs. It builds into the live `umtool/.next` and never restarts after a failed build. Then one restart on :3050 and a smoke: `/` and `/api/jobs` 200, the form offers "Archilyzer Media", no `⨯` in the start log. `r10-rollback-umtool.sh` is its rollback twin. - It adds what readers and the operator will notice. - The YouTube assets point to `~/reports/archilyzer-media/brand/INDEX.html`. - It adds these gates and the link-safety proof. - The copy rulings and new lows are listed. - `bash -n` and `sh -n` pass on all nine scripts. No script was run. | sha | what | |---|---| | `9e3047e3` | `plans:` FACTS: the two worktree workarounds superseded (L1), and the release 10 facts for S4, L1 and L2 | | _this_ | `plans:` this record, STATE ("Merged to main (2026-09-26), NOT rolled out"), the Rollout steps | ### Slice M, as shipped — fetch_clip (2026-09-26) Branch `mcp/fetch-clip` off `main` `4ac32a2e`, worktree `/home/user/Projects/mcp-fetch-clip`, one Opus implementer. The plan is [`mcp-fetch-clip.md`](mcp-fetch-clip.md), §1–§5 and its Amendment (`full: true` exposed). The MCP server gains ONE tool, `fetch_clip`, which asks the local editor for the media behind a cited moment through its existing `POST /api/media/fetch-window`. So an agent following `/ask` or `/sweep` no longer has to shell out to yt-dlp (unpaced, no cookies, bytes outside the corpus) or stop. The guidance now makes the tool the way, and `yt-dlp --download-sections` only the no-editor fallback. Nothing in `common/`, `editor/`, `export/`, `scripts/` or `umtool/` changed; no settings, site or channel key; nothing on disk. **The client** (`mcp/src/fetchClip.ts`, pure; `env`, `fetch`, `sleep` and `now` injected, no MCP imports). - `parseSeconds`: a number, `ss`, `mm:ss` (minutes may pass 59) or `h:mm:ss`. - `planWindow`: umtool's arithmetic, `from = max(0, start-pad)` and `to = end+pad`, each `.toFixed(2)`; the 900 s cap (`MAX_CLIP_WINDOW_SECONDS`, imported) applies AFTER padding. - `validateFetchClipArgs`: a `job` alone is a whole request and every other argument is ignored; `full: true` ignores `start`/`end`/`pad` (not required, not validated); `reason` is required in both modes; `wait_seconds` is clamped to [0, 300], default 90. - `fetchClip`: POST (unless resuming), then poll every 1 s via `deps.sleep` until the job is terminal or `deps.now()` passes the deadline. A resume polls before it sleeps. The body is `{channelSlug, videoId, webpageUrl?, from, to, pad, requestedBy: "mcp", manifest, reason}`, the reason cut to 400; in full mode exactly `{channelSlug, videoId, full: true, requestedBy, manifest, reason}`. - `renderFetchClip`: the plan's texts, verbatim where the plan gave them. - HTTP only: the MCP process writes nothing. **The tool** (`mcp/src/server.ts`): the schema after `get_video_metadata`, `required: []`, `additionalProperties: false`. `createServer(source, {fetchClipDeps})` defaults to `process.env`, `globalThis.fetch`, a `setTimeout` sleep and `Date.now`. `source` resolves first and `withCorpus` adds the trailer, as for every tool. `handleFetchClip`: - **no editor configured** is said before argument validation and before any corpus read; - `findVideo(source, video, channel)`; - the id sent is `extractVideoId(record.webpageUrl) ?? video` (the editor's own directory naming — a Rumble embed id `vxe1ae` becomes `v1007ay`), and the record's `webpageUrl` rides along in window mode; - a video `source` does not hold goes through as cited, with the plan's `note:` prefix. **The plans** (`mcp/src/instructions.ts`): one shared `clipStep`, after "Answer with citations" in ask and before **Finish** in the sweep. It carries the plan's sentence with the amendment's clause. `wait_seconds` and `full` are not backticked, and `NOT_TOOLS` is unchanged. **Docs.** - `README.md`: both `claude mcp add` blocks carry the two optional `--env` lines. In "Clips and video", step 3 is `fetch_clip` (window → `clips/`, `full: true` → the saved-video store), the editor path no longer claims `out/clips-raw`, and yt-dlp is the no-editor fallback. - `mcp/README.md`: the one exception to "writes nothing", the tool row, and "Still read-only" now says the editor writes. - `AGENTS.md`: the env lines, and the clips loop through `fetch_clip`. - `grep -n 'download-sections' README.md AGENTS.md mcp/README.md` gives two lines, both in no-editor fallback sentences (`AGENTS.md:87`, `README.md:366`). **Where the texts depart from, or fill in, the plan** (for the reviewer): - **A 503 is a token problem only when its error says "disabled".** The plan put every 401/503 on the token text. But `fetchWindowAction` answers 503 for an unreachable-media refusal (`videoActions.ts`, the `!res.ok` after `runManagedFunction`), and `fetchFullSourceAction` for any `archiveSourceVideo` error that is not low disk. Under the token text, "plug the drive in" would have read as "fix your token". Such a 503 goes through the generic `The editor refused (HTTP 503): `. - **The channel sent is the record's own `ch.slug` when the video is found** (the plan: the `channel` argument). A citation that spells the channel by name or in another case still reaches the right directory. When the video is not found, the argument goes as given. - Texts the plan left open: - `Already on disk — the exact window: –.` - `requested by ` is a footer line, shown for a cached whole recording too (`SavedVideoOrigin` carries it). - The footer names the window's real sidecar (`7.00-23.00.json`). - A missing start/end reads `fetch_clip: start is required (seconds, mm:ss or h:mm:ss) — or pass full: true for the whole recording`. - A bad pad reads `fetch_clip: pad "

" must be a finite number of seconds, 0 or more`. - A done window job with no file uses the amendment's full-mode "finished but named no file" text. - A poll 401, or a disabled 503, adds the token sentence. - A resume knows no channel or video, so its "Fetched …" line omits "of /

`. - Values are cut at 300 characters, with the full text kept for `title=`. - `atLabel` is fixed UTC. - `page.tsx` reads the sidecar once beside `availability.json`. The new server component `components/MetadataHistoryDetails.tsx` draws it in the page header, under the Description: a `
` whose summary is the line, with each entry's keys as from → to and the volatile keys named. No client state and no control. **Where the code departs from, or fills in, the plan** (for the reviewer): - **The "Persist source video" button had to be un-gated.** `SourceVideoSection` returned "Source-video persistence applies to transcribe-handling channels only." for any other handling, so the plan's e2e 6(a) — click the button on a youtube-handling video — had no button to click. - The gate is gone. - A youtube channel gets its own description line: "…YouTube's subtitles are fetched again first, as on any re-download; a Whisper transcript is not touched, and no audio is extracted beside a transcript." (review M1: the first wording said the transcript was "kept", which a re-fetched VTT is not). - Labels, aria-labels and test ids are unchanged. - `videoChoreCards.ts`'s `shown` text is updated. - **`keepTranscript` with a plan that persists nothing fetches nothing.** The plan said "do not assume `plan.persist`". With a transcript the pass may extract no audio, so a plan that keeps no container leaves the download with no destination. It logs `forceMedia: a transcript is on disk and this download keeps no source video (); nothing to fetch.` and skips. Unreachable today: every `forceMedia` caller sets `keepSourceVideoOverride: true`. - **The summary names counters when nothing else moved:** `only counters (view_count, …)`. - "nothing meaningful (formats only)" is kept for an all-volatile entry; `unreadable metadata (no diff)` for a torn side. - Real rewrites nearly always move a counter, so without this the line would read "formats only" over a view count that changed. - **An unparseable side** records the rewrite (both fingerprints) with an empty diff and `unparseable: ["from"|"to"]`, rather than diffing against `{}` (which would list every key). - **A counter present on one side only** is `[null, x]` in `counters`, not in added/removed. - **The surface is in the header, not in a card.** There is no metadata card. The header is where the page shows the metadata, and a header `
` is visible without opening a collapsed card. - **README: nothing.** `grep -n "Persist source video" README.md` is empty. Its `full: true` line ("it needs a video the editor already knows") is still true. | sha | what | |---|---| | `92ae844c` | `common:` `metadataHistory.ts` + `metadataHistory-server.ts`; `metadataHistory.test.ts` (13), `metadataHistory-server.test.ts` (5); the sidecar enumeration test names ten | | `51166736` | `common:` `forceMedia` (attempt-3 gate, the log line, the subs refusals, persist-only finalize); the three history wraps + `dataDirIdForUrl`; `archiveSourceVideo` and `persistKept` pass it; `forceMedia.test.ts` (6) | | `1877d3a5` | `editor:` `views/metadataHistoryView.ts` (+ test, 6), `MetadataHistoryDetails.tsx`, the page loader; the Source video card un-gated | | `92c26c73` | `e2e:` `persist-youtube-handling.spec.ts` (2), `fetch-window.spec.ts` +1, the fake's `.fake-ytdlp-metadata.json` knob | | `6adbd455` | `docs:` AGENTS.md, "Clips and report-to-video" | | `31eeece4` | `common:` the `PREFETCH_OWN_FILES` comment (comment only) | | _this_ | `plans:` this record; two `[Unreleased]` bullets; the FACTS amendment (slice M's "silent no-op" is fixed, with the VTT-overwrite fact) | **Gates**, all from the worktree root: - **tsc** (`pnpm -r --no-bail --workspace-concurrency=1 exec tsc --noEmit`) clean before every commit (`n-tsc1.log` … `n-tsc5.log`). - **common 1,984/1,984** (on `6adbd455`). That is 1,954 + 30: `metadataHistory` 13, `-server` 5, `forceMedia` 6, the view 6. - **editor unit 85/85.** - **`test:scripts` 173 + 1 skip of 174.** - **mcp 269/269**, unchanged (`n-gates1.log`). - **`pnpm --filter editor exec next build`** ok: compiled in 17.5 s, 50 s total (`n-build1.log`). - **e2e** (queued, detached, `export/public` links in place, no dangling links): **78 passed, 0 failed, 8.7 min** (`n-e2e1.log`), on `92c26c73` (the two commits after it are AGENTS.md and a comment). The specs: - the plan's: `saved-videos`, `fetch-window` and the new `persist-youtube-handling`; - the grep for `downloadOneManaged|Persist source video|persist-kept|redownload`: `import-video` and `incomplete-transcript` (plus `saved-videos`); - the paths this slice touched: `no-subs-fallback` (attempt 3), `video-page` (the header), `title-filter` and `chat-only` (the prefetch wrap beside `discardPrefetchDir`), `skip-live` (the prefetch), `audio-check-scenarios` (the audio-check wrap). - **The new tests bite.** - e2e: with `forceMedia: true` removed from `archiveSourceVideo` and `withMetadataHistory` made a pass-through (uncommitted), `persist-youtube-handling.spec.ts fetch-window.spec.ts` gave **6 passed, 3 failed, 1.3 min** (`n-bite1.log`), and the three failures are exactly the new tests: - the full fetch ends `done` with `file` undefined (slice M's live no-op, reproduced); - no `forceMedia:` line; - no `metadata.history.json`. - unit: with the attempt-3 gate forced off, 3 of the 6 `forceMedia.test.ts` fail; with `extractAudio: true`, 1 fails. - **Numbers tool: none**, as the plan says. Nothing was run against the live :3001 editor or the real corpus. **Found and left.** - **The primary subtitle pass rewrites a `transcript..vtt`.** - This is not new: the decision keeps that pass "as today", and every re-download already does it. yt-dlp deletes and re-fetches an existing subtitle file unless `--no-overwrites` is passed (`YoutubeDL.existing_file`, `default_overwrite=True`; verified in the installed `yt-dlp-patched` 2026.08.19). - So the rollout's check "the transcript's mtime unchanged" on `teamrcn/dbnS-cBgStY` holds for a whisper `transcript.json`, not for a VTT. A VTT's bytes are what YouTube serves now. - What slice N guarantees is that the MEDIA pass writes no transcript. The new e2e asserts it on a `transcript.json`, with inline whisper on. - **A title-filter rejection now keeps a directory that has a history** (the plan's allow-list rule). This takes a dir left by an earlier pass that was not rejected (a failed download) and a filter that rejects it now. Real rewrites always differ (`epoch`), so such a dir gets a history and keeps its metadata stub. - **Two history entries per audio-checked download** (the prefetch, then the audio-check primary's own re-extraction), as the plan's wraps imply. - **No history for an unidentifiable URL** (the `%(id)s` dir is known only afterwards), nor for a rewrite by a pass the plan did not wrap: the subtitle primary and the fallback load the prefetched info json and write none, unless a channel's `ytdlpExtraArgs` carry `--write-info-json`. - **"Persist kept now" on a youtube-handling channel now downloads every kept video's source** (the plan's known limitation). `persistKept` still counts a returned-but-failed download as `persisted` (pre-existing). - **A forced download that fails on a video with a transcript marks the whole download failed** (review L2; a low for a later slice, not fixed here). - The `else` branch after the attempt-3 run (`downloadOneManaged.ts:1470-1472`) sets `status = "failed"` and `lastSucceeded = false` for a `keepTranscript` pass too. - So the video page shows "Download failed" on a video whose transcript is fine. - The whole-recording fetch job still ends `done` with no file, and the MCP says "finished but named no file". - yt-dlp's `source-media.*.part` (from `bestvideo*+bestaudio`, possibly several GB) can stay in `data//` until a later persist resumes it. - These are the same mechanics as today's no-subs fallback, now reachable on youtube channels. - The review's option: on a `keepTranscript` failure, keep the subtitle pass's status and record only the failed attempt. **Review fixes** (review SHIP AFTER FIXES, `n-review.md`: one medium, three lows; the three questions ruled as asked — the card un-gated, no `--no-overwrites`, the history file kept off `PREFETCH_OWN_FILES`). 1. **M1: nothing the operator reads says the transcript is "kept"** (`3067f8af`). On a youtube-handling channel the subtitle pass deletes and re-fetches `transcript..vtt` (and the live chat) before the media pass runs, so "kept" was true only of a Whisper `transcript.json`. - The Source video card, the `[Unreleased]` bullet and the AGENTS.md paragraph now say: "YouTube's subtitles are fetched again first, as on any re-download; a Whisper transcript is not touched, and no audio is extracted beside a transcript." - The AGENTS.md sentence also says the pass was skipped for a transcript *or captions* (nit N5). 2. **L1: a test that `persistKept` passes `forceMedia`** (`42f836cd`, `controller/persistKeptForceMedia.test.ts`, 1 test). - Setup: youtube handling, `keepLatest: 1`, a kept video with a `transcript.json`, `SETTINGS_FILE` set to a temp file with the disk floor off. - Asserts: three spawns, the last without `--skip-download`; `persisted: 1`; the pointer (`override`) and the container in the store; no `audio.*` or `source-media.*` in the data dir; the transcript's bytes unchanged. - It bites: with `persistKept.ts:137` removed it fails. 3. **L2:** recorded above, under "Found and left". 4. **L3: the full editor suite** (below). - **Nits not taken:** - N1 is covered by M1's "beside a transcript". - N2, N3 (a 300-unit cut can split a surrogate pair), N4 and N6 are cosmetic. | sha | what | |---|---| | `3067f8af` | M1: the card text, the changelog bullet, the AGENTS.md clause (+ N5) | | `42f836cd` | L1: `persistKeptForceMedia.test.ts` | | _this_ | `plans:` these fixes, L2, the full-suite gate | **Gates on `42f836cd`:** - **tsc:** clean before each commit (`n-tsc6.log`, `n-tsc7.log`). - **common 1,985/1,985** (+1, the L1 test). - **editor unit 85/85.** - **`test:scripts` 173 + 1 skip.** - **mcp 269/269.** - **Editor build:** `pnpm --filter editor exec next build` ok, compiled in 19.9 s, 54 s total (`n-gates2.log`). - **The FULL editor e2e suite** (`pnpm e2e`, no spec list; queued, detached, `export/public` links in place with none dangling): **641 passed, 2 failed, 12 skipped of 655, 48.6 min** (`n-e2e-full1.log`). Each failure was re-run alone ×3 (`pnpm e2e cookies-mode.spec.ts:241 tags.spec.ts:220 --repeat-each=3`): **5 passed, 1 failed, 3.0 min** (`n-rerun1.log`). - `tags.spec.ts:220` ("a video whose description and transcript are large previews and renders"): **3/3 alone — a flake.** - The failed assertion is the `addTag` helper's `tags-saved` wait (5 s), under load. - The test downloads nothing, so no `metadata.history.json` exists and the new header block renders nothing. - `cookies-mode.spec.ts:241` ("defer: … downloads via the bucket button"): **2/3 alone — an intermittent that predates this slice.** - Both failures are the same: `getByLabel('Retry needs cookies output')` → "element(s) not found" while waiting for "Managed download complete". The captured log shows the download itself finished (`single-url fetched vidcookiegated1`). - The cause: `NeedsCookiesList` (`editor/app/channels/[slug]/components/stages/DownloadStage.tsx`) returns `null` when the bucket empties, so the refresh after the successful download unmounts the card and its run log before the last line arrives. That is the FACTS "a run log lives in the panel's React state" hazard, in a file this slice does not touch. - Nothing of this slice runs on that path. The gated video's first prefetch fails before writing metadata, so there is no history. The retry is not a forced download, and its transcript comes from the subtitle pass, so there is no attempt 3. - Left for a later slice: render the card while its run lives, as `SourceVideoSection` does. ### Slice O, as shipped — the Ko-fi mark on every site (2026-09-26) Branch `export/kofi-mark` off `main` `9860bcbe`, worktree `/home/user/Projects/export-kofi-mark`, one Opus implementer. The plan is [`kofi-mark-on-sites.md`](kofi-mark-on-sites.md), Implementation items 1–5. The operator's 2026-09-26 ruling supersedes the 2026-09-25 one ("a site build carries no Ko-fi link"). Every export build, each site and the hub, now ends its footer's social row with Ko-fi's official symbol. "No copy" still holds: the accessible name is exactly "Ko-fi" and nothing else is visible. The homepage is untouched and keeps its bare text link (decision 4). **The asset.** `export/public/kofi-symbol.svg` is `kofi_brandasset/kofi_symbol.svg` from `~/Downloads/kofi_brandasset.zip`, byte-for-byte: 3,234 bytes, sha256 `f5a2d5f1abb5c7f13392ecf6e7d5dffc8bef77917ab59c28a6080deab52f5078`. The same hash comes from the unpacked copy, from `unzip -p` of the zip, from the tracked file and from the build's `out/kofi-symbol.svg`. It is a tracked real file beside the five starter SVGs. `.gitignore` names the generated `export/public` entries one by one, so nothing ignores it, and the worktree's `export/public` links from the primary leave it alone. **The footer** (`export/app/components/Footer.tsx`). - The social `