# Release 16 — what a search reads `main` at `9a259787` (releases 14 and 15 merged and rolled out). No plan file of its own: each slice's prompt carries its ruling, and this record carries what was built. Rules: `plans/tools/implementer-rules.md`, with the commit trailer this release's prompts give. **The standing choices** (not re-opened): - **A search reads transcripts and posts unless the visitor says otherwise; live chat is read only when asked.** On `main` before this release a plain query read the transcript cues only: posts only through a query-builder leaf whose scope is "Posts", live chat only through one whose scope is "Live chat". A plain query now reads transcripts and posts by default; the three become one row of toggles. (Corrected after slice CK found the first wording, "a plain query already reads … the posts corpus", untrue of the code.) - **The toggles say what a query reads, not which records list.** "Type: Videos, Livestreams" keeps saying which records are shown; an empty query with Transcripts off still lists videos. - **A slice that needs another slice's file stops and says so**; it does not edit it. - **Nothing is edited in the primary checkout**; each slice has its own worktree, and the parent merges with `git merge --no-ff` only on a clean tree. ## The slices | Slice | Branch | What | Owns | |---|---|---|---| | CK | `r16/search-in` | A "Search in" row — Transcripts, Posts, Live chat — on the export and hub search, Transcripts and Posts on by default | `common/components/{FiltersPanel,SearchSessionContext,SearchResults,SearchBar,exportFilterStorage}.tsx/.ts`, `common/lib/searchQuery.ts` and `common/lib/search/*` as its prompt names, `export/e2e/search-in.spec.ts` (new) and the specs its prompt names, `export/e2e/helpers.ts`; records: `plans/FACTS.md` | | DX | `r16/research-setup` | The research-only setup (source → `pnpm install` → `claude mcp add archilyzer` → `/ask`) in one place, the homepage's AI and MCP doc; the sites' and the hub's Use-with-AI page removed and its links pointed at the doc; `README.md` §1/§4 and `mcp/README.md` their own copies (as amended) | `homepage/content/docs/ai-and-mcp.md`, `export/app/use-with-ai/` (removed), the Use with AI links (`export/app/components/{Header,MobileMenu,Footer}.tsx`, `export/app/(workspace)/ask/page.tsx`), `common/lib/{project,corpus}.ts` + `common/bin/compose-site.ts` (what named the page), `mcp/README.md`, `README.md` §1/§4 (wording only), `homepage/e2e/docs.spec.ts`, `export/e2e{,-hub}/use-with-ai-link.spec.ts` and the specs that visited the page | | FK | `r16/forms-keep-input` | Every editor form keeps what was typed when its action fails: actions return the submitted values with the error, the shared field helpers seed from them | new `editor/app/lib/formState.ts` + test; `editor/app/components/forms/Field.tsx` and the local `Field`s in `SiteForm.tsx`, `ChannelForm.tsx`; every action that returns `{ok:false,error}`/`{error}` (sites, settings, operations/settingsActions, scheduler, storage, homepageActions, cutReleaseAction, channels, videoActions); the 15 forms the ruling lists; e2e `forms-keep-input.spec.ts` (new) + the existing `sites-crud`, `settings`, `channels` specs; records: `plans/FACTS.md`. As shipped, also `editor/app/components/forms/Controlled.tsx` (new) and one tag swap each in `DurationField`, `SocialLinksField`, `SiteMembershipsSection`, `WorkersField` ("Slice FK, as shipped") | | RM | `r16/move-holds-writers` | A media move holds the channel's writers and mirrors its copy, so a transcription or download during the move cannot fail it | `common/controller/relocateChannelMedia.ts` (+ the saved-video mover if it shares the code) + tests; the lane runners' per-channel skip (`common/controller/autoRunner.ts`, the backfill/digest/normalize/clip-fetch entry points, `common/lib/channelMediaHold.ts`); `common/jobs/jobKinds.ts` (`needsMedia`); the channel page's Storage panel and the rack's reason text; `editor/e2e/relocate*.spec.ts`; records: `plans/FACTS.md`. As shipped, also `common/controller/{channelWriters,relocateDir}.ts` (+ tests), `common/jobs/streamCommand.ts`, `common/views/channelRow.ts`, `editor/app/channels/lib/{mediaBusy,relocationJob}.ts`, `editor/app/storage/lib/{storeBusy,savedVideosJob}.ts`, `editor/app/api/test/stuck-job/route.ts`; the relocate spec is `editor/e2e/channel-storage.spec.ts` ("Slice RM, as shipped") | | XL | `r16/x-login` | Connecting an X account works: the Connect window is the operator's own browser without automation signals, and the fetchers can use the operator's browser login directly (`cookiesFromBrowser`) with no window at all | `common/social/{xSessionBroker,xGalleryDlFetcher,fetchers,playwrightRuntime}.ts` + tests; `common/lib/settingsSchema.ts` (one key) + `SETTINGS.md`; `editor/app/settings/{xSessionActions.ts,components/XSessionSection.tsx}`; `editor/e2e/settings*.spec.ts`; records: `plans/FACTS.md`, `ENVIRONMENT.md` if an env var is added. As shipped, also `common/social/{xBrowser,xCookieSource,xBrowserLogin,nodeSqlite}.ts` and `__fixtures__/firefoxCookieStore.ts` (new), `xPlaywrightFetcher.ts`, `common/controller/fetchPosts.ts` (the source per channel), `common/lib/{settingsDocs,envVars}.ts`, `editor/app/settings/page.tsx`, and `editor/e2e/x-session.spec.ts` — the spec that covers the section ("Slice XL, as shipped") | ## Slice CK — the ruling (2026-09-30) - One row, **"Search in"**, beside the Type row of the Filters panel: **Transcripts** (on), **Posts** (on; the Posts box moves here from the Type row, same storage key `nop`, same accessible name), **Live chat** (off; offered only when the site's subs manifest reports live chat, as Posts is offered only when the site ships posts). - The row governs every query leaf whose scope is "transcripts" (the default leaf): it reads the cues of the kinds ticked. A leaf whose scope is "Live chat" or "Posts" was asked for by name and is not changed by the row. - With all three off the Search button does nothing and the panel says which row to fix; the visitor cannot commit a query that reads nothing. - A live-chat hit inside a video's row wears the "live chat" track badge whenever the visitor could also be seeing transcript hits — the suppression at `SearchResults.tsx` for that one track ends. - Stored like the other toggles (the working snapshot and the profiles, `ytdlp-tb:export-filters`), only when off the default. Share links do not carry the row this release (they do not carry `nop` either); a `qt=` link a visitor opens reads with the visitor's own row. - The hub's search gets the row through the shared components; nothing hub-specific. ## Slice DX — the ruling (2026-09-30, amended by the operator the same evening) - **The setup lives in ONE place: the homepage's AI and MCP doc** (`/docs/ai-and-mcp/`). It tells a visitor who only wants to run Claude Code against a public instance, in one block: get the source (the mirror clone or the tarball on `/downloads/`), `pnpm install`, register the MCP server **as `archilyzer`** against the instance of their choice (`TRANSCRIPT_SITE_URL`; `TRANSCRIPT_HUB_URL` to federate), start `claude`, try `/ask`. Windows → WSL2, one line. The optional editor lines for `fetch_clip`, one sentence. The reason for the name (the shipped `/ask` and `/sweep` call `mcp__archilyzer__…`) sits beside it. What that page already says about the published contract (`/corpus.json`, `/llms.txt`), the MCP server and the honesty features stays. - **The sites' `/use-with-ai` page is removed** (export and hub). Its footer entry, its header entry and the link on Ask AI point at the homepage doc instead (`PROJECT_URL` + `/docs/ai-and-mcp/`, same tab), keeping the label **Use with AI**. Nothing a site said there is lost: the contract lines are on the homepage doc, and a site's own `/llms.txt` still describes the site. Specs that used the page as "another page" (`first-search`, `restore-no-refire`, `responsive`) use `/changelog/` instead. - **The READMEs are their own copies**: `README.md` §1/§4 and `mcp/README.md` say the same steps in the same order and register `archilyzer`; the record notes that the homepage doc and the READMEs are two places to change together (there is no shared source between markdown in `homepage/content` and the repo's READMEs this release). - No code path other than the removed page and its links changes. ## Slice FK — the ruling (2026-10-01) - **A form that fails keeps what was typed.** React resets a `
` when its action's transition commits, success or failure; an uncontrolled field (`defaultValue`, `defaultChecked`) is wiped and a controlled one survives. Every save action that can fail returns the submitted values beside the error — `{ ok: false, error, values }` (the `channels` flavour `{ error, values }`), `values` being the raw form entries captured once at the action's top — and every field helper seeds `defaultValue`/`defaultChecked` from `values` when present, else from the initial value. A checkbox absent from `values` is unchecked when `values` is present (an unticked box sends nothing). - One shared type and one helper (`editor/app/lib/formState.ts`); the three `Field` helpers take the live state; the structured widgets that already survive (`SocialLinksField`, `WorkersField`, `DigestAppsField`, `DurationField`, `SiteMembershipsSection`, `LocationForm`, `EditorTagsClient`) are the model and are not changed. - The forms, in order of the operator's pain: `SiteForm` (create and edit), `SettingsForm`, `ChannelForm` (create and edit), then `AttributionSettingsForm`, `DiarizationSettingsForm`, `DigestSettingsForm`, `LaneSettingsForm`, `SchedulerSettingsForm`, `HealthTimingForm`, `HomepageConfigForm`, `CutReleaseForm`, `RenameChannelForm`, `DeleteChannelForm`, `DeleteVideoDirSection`, `SavedVideosControls`' checkbox. All of them this slice. - A success path is unchanged: a form that redirects or re-renders fresh after `{ ok: true }` keeps doing so. - Labels and test ids are contracts; nothing on disk changes shape. ## Slice RM — the ruling (2026-10-01) - **A move refuses to start while a job is running on the channel** (download, transcription, normalize, digest, clip fetch, metadata refresh, snapshot — anything that writes under its `data/`), naming the job; the operator waits or cancels it. A move never waits silently. - **While `.relocating.json` exists, the channel's writers are held:** every lane runner skips the channel (the hold's sentence says "its media is moving"), every `needsMedia` job refuses to start on it (it does today through `in-transition`; the slice proves which writer got past that on 2026-09-30 — the transcription of `v50t5yt` ran during the copy — and closes it), and the hold clears when the move completes or is abandoned. The rack and the channel page show the hold. - **The copy mirrors.** The last copy pass before verification is `rsync -a --delete` from source to the destination copy (the destination is the copy under construction; the source is never the target of `--delete`), and verification is an empty `--dry-run --itemize-changes --delete` plus equal counts. If a source change is seen between the pass and the check, one more pass runs; a second difference refuses and names the file. Resume takes the same path, so an interrupted move with stale files on the destination (the 2026-10-01 case: a transcriber's scratch dir copied, then deleted from the source) completes. - **Remediation, when a move still fails its verification:** the refusal lists the differing paths by kind — extra on the destination, missing on the destination, changed — and the Storage panel offers **Reconcile and resume**: one job that runs the mirror pass (`--delete` toward the destination copy), verifies, and finishes the move; the operator never deletes a file by hand. (2026-10-01 the operator and the session did exactly that by hand for `realcandaceo`: five files of a transcriber's scratch dir removed from the destination copy, then Resume.) A failure the reconcile cannot settle (a changed file that keeps changing) refuses once more and names it. - The on-disk marker's shape does not change; nothing else about where media lives changes. ## Slice XL — the ruling (2026-10-01) - **Why it failed:** the Connect window is Playwright's bundled Chromium launched with its automation signals on (`--enable-automation`, `navigator.webdriver`, the "controlled by automated test software" bar). Google refuses such a browser ("this browser or app may not be secure"), and X's own login form stalls in it. The operator saw both on 2026-10-01. - **The Connect window is the operator's own browser:** the system Chromium/Chrome when one is installed (found on `PATH` or a configured path), else the bundled one — in both cases launched without the automation signals (`ignoreDefaultArgs` for `--enable-automation`, `--disable-blink-features=AutomationControlled`, no infobar), in the broker's persistent profile as today. The section says plainly that Google's sign-in may still refuse an embedded browser and that the X password login is the reliable path there. - **The fetchers can use the operator's browser login directly, with no window:** when `cookiesFromBrowser` is set (it is: `firefox`), the X fetchers pass `--cookies-from-browser` to gallery-dl (it supports it) and read the same cookies for the other X paths, fresh on every run — the operator stays logged in to X in their everyday browser and nothing expires. One setting chooses the source — `social.x.cookieSource: "browser" | "profile"` — defaulting to `browser` when `cookiesFromBrowser` is set and no profile is connected, else `profile`. The section shows which source is in use and whether an X login is visible in it (an `auth_token` cookie for `x.com`, read from the browser's cookie store read-only), with a "Check" button. - Nothing is automated against X's login; no credentials are stored; the cookie jar's format and path do not change. ## Record ### Slice CK, as shipped — a search reads what the visitor ticked (2026-09-30) Branch `r16/search-in` off `main` `6c6dcd04`, worktree `~/Projects/plans-export-header-first-search` (editor 3401, test 3411, export 3410; the export suite's server on 3420), one Opus implementer. Scratch files `ck-*` in the job's `tmp`. The ruling is above ("Slice CK — the ruling"). **The ruling's "today" was not the code; the slice built the ruling.** On `main` a plain query — a leaf of scope "transcripts" — read the transcript cues and nothing else. The Posts box in the Type row put the posts corpus into the global scope only when a leaf of scope "Posts" was in the tree (`needsPostsManifests`), and every other leaf subtracts the posts slugs (`searchEval.ts:337`), so a plain query never read a post. The row as ruled (Posts ticked by default, the row governs the transcripts leaf, the toggles say what a query reads) therefore makes a plain query read posts on a site that ships them: what a plain search returns there changes. Taking it back is one flag — `posts` false in the session's `committedSearchIn` and `draftSearchIn` — and the Posts box would then govern only a "Posts" leaf, as before. **What was built.** - **The row.** "Search in" beside Type in the Filters panel (`FiltersPanel.tsx`, `data-testid="search-in-row"`): **Transcripts** (ticked), **Posts** (ticked; the box moved out of the Type row with its key `nop`, its accessible name and its gate, `postsManifest.channels` non-empty), **Live chat** (unticked; offered only when `subsManifest.liveChatTotalCount > 0`). The Type row keeps Videos and Livestreams. The hub gets it through the shared components. - **Mechanism (a), the smaller: a rewrite of the committed tree just before it runs.** `applySearchIn(root, {transcripts, posts, chat})` (`common/lib/searchQuery.ts`) turns each active "transcripts" leaf into: - the leaf itself, the same object, with Transcripts alone ticked (so the default row on a site with neither posts nor live chat runs the very tree it was given); - the leaf with the one other kind's scope, same id, same `negate`, with one other kind alone; - an OR group `~in` over copies `~transcripts`, `~posts`, `~chat`, each with the leaf's `contributeHits`, with two or three kinds. A negated leaf is NOT of the union: the OR sits inside a negated one-child AND `~not`, so the OR's own group state is the union whatever `negate`; - with nothing ticked, the leaf unchanged (see the refusal below). Posts are not left on the global-scope path, as the prompt proposed: that path reads posts only for a "Posts" leaf, so the rewrite makes one (the posts copy) and the path then feeds it. `nop` acts only here, by leaving the posts copy out; it no longer gates the global scope (review M2), so a leaf whose scope is "Posts" reads posts with the box unticked, as ruled. Under a curated-tag filter no posts copy is made either (`searchInUnderTags`; a post carries no tags), unless posts are all the row reads — then the copy stays and reads an empty scope, and the leaf does not fall back to its transcripts. A leaf of any other scope, and an empty leaf, are untouched. (b) would have run up to three pipelines per leaf inside `runLeaf`, each with its own cache key, controller and streaming merge; (a) touches the evaluator only in `runLeaf`'s empty-scope short-circuit (review M1) and in what a cached or empty leaf reports as its progress (re-review R-L1). - **The fold.** `foldSearchIn(progress, tree)` (`common/lib/search/searchIn.ts`, new) files the copies' hits back under the visitor's leaf id (a chat hit keeps `scope: "chat"` and `track: "live_chat"`, so it lands in the same section of the same video row, in time order), and folds the copies' states into one: the count is the OR group's union once every copy has started (before that, the evaluator reads a copy with no result as the whole scope, so the largest copy stands in and the leaf shows as active), hits, processed and to-process summed, capped if any, cached if all. Nothing else reads the copies' ids. - **The session** (`SearchSessionContext.tsx`). What counts as ticked is what the site can honour: Posts only with a posts corpus, Live chat only with live chat — the panel's own conditions — so a stored `lc` on a site without chat asks for no subs manifest (see "Found and left" for what a "Live chat" leaf does there). The session waits for the two manifests before it decides anything from the row (review L4): `SearchDataValue.manifestsSettled` — the single site's settles at each manifest's first answer or first failure (a 404 subs manifest is not held for the retry), the hub's is `summariesReady`, since an archive is ready only once both its manifests have settled. Until then Search is not refused and the query does not run, so a stored row with Transcripts unticked never reads transcripts first and then re-runs — on a single site; for the progressive hub see "Found and left". The committed tree runs as `applySearchIn(committedRoot, …)` and its progress is folded before `setTreeProgress`; `needsChatManifests` and `needsPostsManifests` read the rewritten trees (draft and committed), so Live chat ticked loads the subs manifests and passes `chatScopeSlugs` exactly as a "Live chat" leaf does (the prompt's `|| committedLiveChat`, but only when there is a leaf to read it). The run and the hit-cap reset also depend on the rewritten tree's hash, so a change of the row alone re-runs. `committedRoot`, its hash, `qt=`, the builder's leaves and the result cards' sections stay the visitor's tree. - **State.** `notr` (Transcripts unticked) and `lc` (Live chat ticked) in `FilterSnapshot`, threaded like `nop`: `parseSnapshot`, `snapshotsEqual`, the draft/committed pairs, `buildDraftSnapshot`, `committedSnapshot`, `filtersDirty`, `promoteDraftsToCommitted`, `filterKey`, hydration, `applyDraftSnapshot`, `applySnapshot`, each written only off its default. Not in `UrlParams`, not in `ShareSelection`: **share links do not carry the row this release** (they do not carry `nop` either). Hydration reads the row from the stored snapshot (active profile, else working) whatever the URL carries, so a `qt=` link — and a share-v1 link, whose filters otherwise replace the stored ones — reads with the visitor's own row. - **`nop` was not threaded, three ways, and is now.** Hydration set the draft `nop` to false and never set the committed one, so Posts unticked came back ticked on every reload; `committedSnapshot` did not write it, so a profile with Posts unticked always showed as changed against what it had just loaded; `applySnapshot` did not commit it, so a loaded profile with Posts unticked searched posts until the next Search. - **Nothing ticked.** When nothing the site offers is ticked (`draftSearchInEmpty`), Search is disabled, `Apply filters` in the sheet is disabled (`FiltersContainer.tsx`, a new `applyDisabled` prop), Save and Save as… are disabled (`ProfilesRow`'s `saveDisabled`, review L2), and `commitSearch` (where Enter and Apply arrive) and both profile saves (which commit the draft) refuse. The row's line, "Search in: pick at least one" (`data-testid="search-in-empty"`, `aria-live="polite"`), is always mounted and empty unless Search is refused, so the change is announced (review L1; a live region, not `role="status"`, which the first cut used and which gave the page a second "status" beside the modal's — `modal-digest.spec` finds that one by role); the Search button is described (`aria-describedby`) by an always-mounted `sr-only` copy of the words in the bar (`id="search-in-refusal"`), because below xl the panel is a sheet that is not mounted while closed; its `title` stays for the mouse. The bar's "Press Enter or click Search to apply" is withheld while Search is refused. A tree committed with nothing ticked some other way (a hand-edited profile) reads its transcripts rather than matching nothing. - **Display.** The `hit.track !== "live_chat"` suppression in `SearchResults.tsx` is gone: every track hit wears its `TrackBadge`, so a "Live chat" leaf's hits now carry the badge too (no spec asserted its absence; `live-chat.spec.ts` searches nothing and was not changed). A transcripts leaf's section bar is named for what it holds (`sectionScope`): "Posts" on a post's card, "Live chat" when every hit in it is a chat hit, else "Transcripts". - **The bar.** The hint reads "Live chat available on N videos — tick Live chat under Search in." and goes once Live chat is ticked. The Filters chip's count gains one for the row off its default (Live chat ticked widens rather than narrows, but the panel that says so may be behind the chip); Videos and Livestreams keep their own one. **The fixture.** The export e2e fixture's posts said "alpha" and "gamma", like every video's cues. With posts read by default, every spec searching those words for its own reasons got two post cards: a first full run at `2d68d9d9` (stopped at 174 of 265) had 17 failures — 7 in `ask-chat`, 5 in `ask-workspace`, 2 in `posts-search`, 2 in `query-tree`, all post cards, and 1 in `charts` (a browse-mode stacked-bar case the row cannot reach; it passed in both later runs). The posts now say "kappa" and "sigma" (`omega` was already theirs); `posts-search.spec.ts` and `tag-chips.spec.ts` name them in their posts leaves, and `search-in.spec.ts` reads both corpora with a regex `alpha|kappa` in one plain leaf. **Commits** | Commit | What | |---|---| | `0edc628f` | `common:` `applySearchIn`, `SearchIn`, `SEARCH_IN_DEFAULT`, `searchInReadsNothing` in `lib/searchQuery.ts`; `foldSearchIn` in `lib/search/searchIn.ts`; tests. | | `41e75543` | `common:` `notr` and `lc` in the filter snapshot; `parseSnapshot` exported; the round-trip test. | | `2d68d9d9` | `common:` the row; the session (rewrite, fold, manifests, state, refusal, the three `nop` fixes); the bar; the badge and the section names; `applyDisabled` on the sheet's Apply. | | `5699a1e7` | `export(e2e):` `search-in.spec.ts`; the fixture's posts get their own words; `posts-search` and `tag-chips` follow them. | | `861baaa0` | `plans:` this section; FACTS "Search in"; the export changelog. | | `1eb089da` | Merge `main` (`0fe719b1`, the 0.11.0 cut, which renamed `[Unreleased]` while this branch added to it): the bullet goes under a fresh `[Unreleased]` above `[0.11.0]`. Changelogs only; no code moved on `main`, so no gate was re-run. | | `26c63ccb` | `plans:` the two rows above. | | `6980ac75` | `common:` review M1 — the three streaming drivers settle when no worker starts (and after a raised cap); `runLeaf` answers an empty scope at once; tests through the real drivers. | | `b22198d5` | `common:` review M2, L1, L2, L4 and M1's tag rule — `nop` no longer gates the global scope; `searchInUnderTags`; `manifestsSettled` on both SearchData providers and the held decision; the always-mounted refusal line and the button's description; Save and Save as… disabled. | | `aaf3accd` | `export(e2e):` `search-in` cases 9 (the "Posts" leaf), 10 and 11, the refusal's description and Save as…; `posts-search` for M2. | | `76deaac7` | `common:` the refusal line is a polite live region, not a second `role="status"` (it broke `modal-digest.spec`'s `getByRole("status")`). | | `1d7da806` | `plans:` the review, its rulings and gates in this section; the standing choice amended; FACTS; the changelog. | | `500417f3` | `common:` re-review R-L1 — a cached or empty leaf reports its progress over the scope it read (0 of 0 when empty); a real-driver test asserts the folded "searched N/M" only climbs. | | this commit | `plans:` the re-review in this section; "Found and left" gains R-L2, R-I2 and R-I1; the mechanism sentence; the follow-up in `STATE.md`. | **Tests** (unit) | File | What it pins | |---|---| | `lib/searchQuery.test.ts` (11, new) | Transcripts only returns the very same root; the default is Transcripts and Posts; Transcripts + Live chat is `OR(a~transcripts, a~chat)` with both mapped to `a` and `a` → `a~in`; Live chat only and Posts only swap the scope and keep the id; all three in a fixed order; nothing ticked is left alone, and `searchInReadsNothing`; leaves of every named scope and an empty leaf are untouched (same root); a negated leaf becomes a negated AND over an un-negated OR whose copies keep `contributeHits: false`; nested: an untouched subtree keeps its identity, group ids are kept, the input's `stringifyRoot` and `canonicalHash` do not change; two leaves get two sets of copies. | | `lib/search/searchIn.test.ts` (8, new) | Through the real `runQueryTree` with a fake leaf runner: Transcripts alone reads the cues only; Transcripts + Posts returns the video and the post, the post hit filed under the visitor's leaf with scope `posts`; Live chat ticked adds the chat-only video, its hit under the leaf with `track: "live_chat"`, the leaf's state folded (only `a` left, count 2 = the union, not active); Transcripts off + Live chat on: a cue-only word finds nothing; a "Live chat" leaf ignores the row; NOT reads NOT of the union and still counts what it matched. The fold alone: before every copy has started, the largest copy and active (not the 30,000 the evaluator reports); nothing rewritten returns the same progress. | | `components/exportFilterStorage.test.ts` (5, new) | `notr`, `nop`, `lc` survive a JSON round trip; absent reads as the default and a pre-row profile equals one spelling the defaults; a non-boolean is dropped; `snapshotsEqual` tells each apart; the working snapshot and a profile keep the row through `saveStoredState`/`loadStoredState`. | **e2e** (`export/e2e/search-in.spec.ts`, new, 11 cases after the review): (1) the defaults — Transcripts and Posts ticked, Live chat not, exactly one checkbox named "Posts" on the page, Videos and Livestreams still in Type, and the bar's hint, gone once Live chat is ticked; (2) a plain "kappa" returns the two posts under a section named "Posts", and none with Posts unticked; (3) one plain regex leaf `alpha|kappa` returns 3 videos and 2 posts, "5 videos, 5 hits"; (4) "message" (only in one video's chat) finds nothing by default, and with Live chat ticked finds that video, "1 video, 30 hits", every shown hit badged "live chat", the section named "Live chat", and `qt=` still the visitor's one transcripts leaf; (5) Transcripts off + Live chat on: "line" (only in cues) finds nothing, "message" finds the chat video, Transcripts back on finds all three; (6) nothing ticked: Search disabled and described by the words, Save as… disabled, the line shown (always mounted, empty otherwise), the "Press Enter" line withheld, Enter commits nothing (no `qt=`, no results), Live chat or Posts re-enables it; (7) the row survives a reload, is saved with "Save as…", a diverging Search leaves the profile, and loading the profile brings the row back with no unsaved-changes dot and its results; storage holds `notr` and `lc` and no `nop`; (8) an empty query with Transcripts off lists "All videos (3)"; (9) a "Live chat" leaf from `qt=` reads the chat with the row at its default, and a "Posts" leaf reads its two posts with Transcripts and Posts unticked; (10) a plain query under a tag chip finishes with its two tagged videos, and with posts alone ticked says "No matching videos." and finishes; (11) with Videos and Livestreams unticked a plain "kappa" reads the two posts and finishes. `posts-search.spec.ts`: unticking Posts leaves a "Posts" leaf's two posts. #### Gates (logs `$T/ck-*.log`) - **tsc** (all workspaces): clean before each code commit — 71 s at `2d68d9d9` (after removing a truncated, stale `editor/.next/dev/types/validator.ts` left in this worktree by an earlier dev run, which failed the first attempt with TS1002); export's alone after the e2e edits. - **Unit:** | Suite | Result | |---|---| | common | **2,372/2,372**, 84 s (24 new: 11 + 8 + 5) | | mcp | **271/271**, 27 s (it imports `lib/searchQuery.ts`, which only gained exports) | | editor unit, `test:scripts` | not run: no editor file, script or anything they import changed | - **Build:** `pnpm --filter export exec next build` — exit 0, 35 s, `export/public` links refreshed from the primary and none dangling (three that were — `hub-sites.json`, `hub-summary.json`, `duplicates.json`, gone from the primary — removed first). The editor and umtool builds: not run, neither imports a changed module. - **e2e** (detached and queued; none waited in the queue): | Run | At | Specs | Result | |---|---|---|---| | 1 | `2d68d9d9` | the prompt's eight (`search-in`, `posts-search`, `live-chat`, `filter-profile-persistence`, `first-search`, `browse-all`, `query-tree`, `share-current-search`) | 50 passed, **6 failed**, 4.4 min — `search-in` 8/8; the six were post cards (above) | | 2 | `2d68d9d9`, old fixture | the full export suite | stopped at 174 of 265: 157 passed, **17 failed** (above) | | 3 | `2d68d9d9` + the fixture and spec edits | the full export suite | 264 passed, **1 failed**, 14.0 min — `tag-chips` named "alpha" in a posts leaf | | 4 | `5699a1e7` | the eight + `tag-chips` | **72 passed**, 0 failed, 3.5 min | | 5 | `5699a1e7` | the hub suite (`e2e:hub`) | **36 passed**, 0 failed, 1.6 min — its fixtures ship no posts and no live chat, so the row is a lone Transcripts box and no tree is rewritten | | 6 | `5699a1e7` | the editor suite's `export-search.spec.ts` (it drives the export's filter rows) | **19 passed**, 0 failed, 1.0 min | | 7 | `5699a1e7` | the full export suite | **265 passed**, 0 failed, 15.1 min | - **Numbers tool:** none. #### Found and left - **Not governed by the row:** a chart's own search series (`components/charts/useSearchSeries.ts` runs its own tree), `/ask`'s retrieval (`export/app/lib/askRetrieval.ts`, its own OR of keywords — though the grounding it is handed from the search page is the session's results, which the row governs), and the MCP (no row). - **A late manifest after a failure.** The single site's manifests count as settled at their first failure (so a site with no live chat, whose subs manifest 404s, is not held for the retry). A manifest whose retry does answer arrives later and the row re-runs, as a late manifest always did. - **A draft tick restarts the committed run** (review I2, left): the manifests load on the draft's need or the committed one's, and the run waits on both, so ticking Posts or Live chat in the draft cancels a committed run in flight, which re-runs once the manifests are in (cheaply, from the layer cache). The same was already so for a draft "Live chat" leaf. - **Hub mode is covered by reasoning** (review I3): no hub or two-origin fixture ships posts or live chat, so their suites see a lone Transcripts box and no rewrite. The rewrite never touches slugs, and the posts and chat scopes are built from the merged, origin-qualified manifests. - **On the progressive hub, a stored row of posts only or live chat only can read as empty at first** (re-review R-L2). The hub's `manifestsSettled` is `summariesReady`, true once the first archive is ready, and the merged posts and subs manifests grow one ready archive at a time. If that archive has neither, a stored row with Transcripts unticked reads as nothing: the refusal shows, and a `qt=` link runs through the empty-row fallback, reading transcripts, then re-runs when an archive with posts or chat lands. On a single site the manifests settle together, and the claim holds. - **The global scope takes the posts when the draft needs them too** (re-review R-I2): `needsPostsManifests` is the draft's need or the committed tree's. With Posts unticked and a committed negated plain query (`NOT x`), a draft that grows a "Posts" leaf re-runs the committed query, and its result gains every post (the plain leaf read none). Consistent with NOT; the result depends on the draft. On `main` the same held with Posts ticked. - **"Load more results" never resumes a leaf that settled at its cap** (re-review R-I1; on `main` too): a driver resolves `done` when its workers stop at the cap, `runLeaf` drops its controller, and the tree's `setHitLimit` reaches running leaves only. More plain queries reach the cap now that each copy has its own. A follow-up in `STATE.md`. - **A lone Transcripts box.** On a site with neither posts nor live chat (the hub's e2e fixture) the row is one box, whose only effect unticked is to refuse Search. Shown as ruled (Transcripts is not conditional). - **Each post twice under an explicit `(transcripts OR posts)` tree.** With Posts ticked the transcripts leaf reads posts too, so a post matched by both leaves shows one section per leaf. The tree says to read posts twice; the row does not merge leaves. - **"Matching videos (N videos, …)" counts posts as videos** — as before, for a "Posts" leaf. - **The builder's scope select still says "Transcripts"** for the default leaf, which now reads what the row ticks; its placeholder still says "Search transcripts...". Unchanged (the labels are contracts; `filter-profile-persistence` finds the input by that placeholder). - **A "Live chat" leaf on a site with no subs manifest at all waits for ever** (as on `main`): the run waits for `subsManifestReady`, which needs a manifest that 404s. The row does not reach it, since it counts Live chat only where `liveChatTotalCount > 0`. - **A stored `lc` on a site without live chat** is kept (the panel does not show the box, and the session counts it as unticked); it applies again on a site that has chat, since profiles are per browser, not per site. #### Decisions the operator could overturn | What I assumed | The alternative | |---|---| | **A plain query reads posts by default** (the ruling as written; the ruling's "today" said it already did, and it did not) | Posts in the row governs only a "Posts" leaf, and a plain query reads no posts, as on `main` (one flag) | | Mechanism (a), a rewrite before the run plus a fold of the progress | (b), branching inside `runLeaf` | | Posts are read through a posts copy of the leaf, not left on the global-scope path, which reads posts only for a "Posts" leaf | — | | Posts and Live chat count as ticked only where the site has them, for the rewrite and for "nothing ticked" | The stored booleans as they are, whatever the site ships | | The row always renders, even as a lone Transcripts box | Hide it where neither Posts nor Live chat is offered | | With nothing ticked, `Apply filters` and the two profile saves refuse too, and the bar's "Press Enter" line is withheld | Only the Search button | | A committed tree with nothing ticked (a hand-edited profile) reads its transcripts | Matches nothing | | The row is read from the stored snapshot whatever the URL carries, share-v1 links included | A share-v1 link resets the row to its defaults, as it resets the other filters to the link's | | A transcripts leaf's section is named for what it holds ("Posts", "Live chat") | Always "Transcripts" | | The Filters chip counts the row off its default as one, Live chat ticked included | Only unticks count, as narrowing | | The fixture's posts get words of their own, rather than every spec that searches "alpha" or "gamma" growing two post cards (16 failures in four specs by 174 of 265 tests) | Keep the posts' "alpha" and add the posts to each expectation | | The hint goes once Live chat is ticked | Always shown where live chat exists | | Each copy of a plain leaf has its own hit cap, so one reading two or three kinds can show up to 2–3× "Max hits" before "Load more" (as an explicit OR of leaves can); the header counts each video once | One cap shared by the copies | | After the review: no posts copy under a tag filter, unless posts are all the row reads | A posts copy that reads an empty scope | | After the review: the single site's manifests settle at their first failure | At the end of the retry (about a second more on a site with no live chat) | | After the review: the Search button is described by a copy of the words in the bar | Described by the panel's line (no description while the sheet is closed) | #### Review **Verdict: SHIP AFTER FIXES** (`ck-review.md` in the job's scratch). Rulings (parent, 2026-09-30): | Finding | Ruling | Where | |---|---|---| | M1: a copy with nothing to read never finished — none of the three streaming drivers finalized for zero slugs, and the rewrite makes such scopes from a plain query (a posts copy under a tag chip or a channel selection with no posts, a chat copy where no video in scope has chat, a transcripts copy when Type keeps no video), so the query read "searched N/M…" for ever | The drivers settle when idle after their first `ensureWorkers` and after a raised cap; `searchEval.runLeaf` answers an empty scope at once. No posts copy under a tag filter (unless posts are all the row reads). Tests through the real drivers; two spec cases | `6980ac75`, `b22198d5`, `aaf3accd` | | M2: `nop` still emptied a leaf of scope "Posts", against the ruling | `nop` no longer gates the global scope; it only leaves out the plain query's posts copy. `posts-search`, FACTS, the storage comment, this record and the changelog follow | `b22198d5`, `aaf3accd`, this commit | | L1: the refusal line appeared with its text, and the Search button's reason was only a `title` | The line is always mounted; the button is described by an always-mounted copy in the bar | `b22198d5`, `aaf3accd` | | L2: Save and Save as… stayed enabled and silently did nothing | Disabled like Search and Apply | `b22198d5`, `aaf3accd` | | L3: case (9) claimed a "Posts" leaf it did not check | Checked, with Transcripts and Posts unticked | `aaf3accd` | | L4: a stored row with Transcripts unticked read as empty until the manifests answered, and a `qt=` link could read transcripts first | The decision waits for `manifestsSettled`: no refusal, no run | `b22198d5` | | I1: the hit cap applies per copy | Recorded (decisions table) | this commit | | I2: a draft tick restarts the committed run | Left, recorded ("Found and left") | this commit | | I3: hub mode covered by reasoning only | Recorded ("Found and left") | this commit | | I4: the standing-choices paragraph said a plain query already read posts | Amended to what `main` did | this commit | The empty-scope settling also ends a hang that `main` had for a leaf asked for by name: a "Posts" leaf under a tag chip, or a "Live chat" leaf where no video in scope has chat, never finished there either (its own changelog bullet). **Tests after the review:** `lib/searchQuery.test.ts` 12 (+1: `searchInUnderTags`); `lib/search/searchIn.test.ts` 15 (+7: each of the transcripts, posts, chat and description drivers settles an empty scope and settles again after a raised cap; a plain query finishes through `runQueryTree` with the real drivers, with the posts scope null, an empty posts set, and an empty chat set, and with posts alone and none to read). All seven new cases fail on the code before the fix; the drivers' fix alone passes all 15, the `runLeaf` short-circuit alone passes the three `runQueryTree` cases. #### Gates after the review (logs `$T/ck-*.log`) - **tsc** (all workspaces): clean, 70 s before the fix commits and 42 s at `76deaac7` (common and export alone before that commit). - **common:** **2,380/2,380**, 79 s (+8). - **Build:** `pnpm --filter export exec next build` — exit 0, 33 s; no dangling `export/public` link. - **e2e:** | Run | At | Specs | Result | |---|---|---|---| | 8 | `aaf3accd` | `search-in`, `posts-search`, `query-tree`, `tag-chips`, `live-chat` | **56 passed**, 0 failed, 2.5 min | | 9 | `aaf3accd` | the full export suite | stopped at 214 of 267: 213 passed, **1 failed** — `modal-digest`'s `getByRole("status")` found the always-mounted refusal line beside the modal's status; fixed in `76deaac7` | | 10 | `76deaac7` | the full export suite | **267 passed**, 0 failed, 13.5 min | | 11 | `76deaac7` | the hub suite | **36 passed**, 0 failed, 1.5 min | #### Re-review **Verdict: SHIP**, with three small things landed before the merge (parent's rulings, 2026-09-30): | Finding | Ruling | Where | |---|---|---| | R-L1: an empty-scope copy reported its parent scope as processed, so the folded "searched N/M" started full and then fell back (a cached copy did the same) | `applyCached` takes the size of the scope the leaf read: 0 of 0 when empty, a cached leaf's effective scope otherwise. The real-driver test with the posts scope null asserts the folded leaf's processed and fraction never fall; it fails on the previous code | `500417f3` | | R-L3: "(a) touches the evaluator not at all" was stale | The sentence names `runLeaf`'s empty-scope short-circuit and the progress `applyCached` reports | this commit | | R-L2: on the progressive hub a stored posts-only or chat-only row can read as empty until an archive with posts or chat is ready | Recorded ("Found and left"); the single-site claim is marked single-site | this commit | | R-I2: the global scope takes the posts when the draft needs them too | Recorded ("Found and left") | this commit | | R-I1: "Load more results" never resumes a leaf that settled at its cap (on `main` too) | Recorded ("Found and left") and a one-line follow-up in `STATE.md` | this commit | **Gates after the re-review:** tsc (all workspaces) clean, 48 s; common **2,380/2,380**, 84 s (the count is unchanged: one test extended); `pnpm --filter export exec next build` exit 0, 29 s; `search-in`, `query-tree` and `posts-search` at `500417f3`: **38 passed**, 0 failed, 1.9 min. The full export suite and the hub suite were not re-run, as the parent directed. ### Slice DX, as shipped — the research-only setup, told where a visitor reads (2026-09-30) Branch `r16/research-setup` off `main` `2f09b065` (first cut at `2b767bc9`, reset on the amendment), worktree `~/Projects/homepage-social-visible` (block #3: editor 3301, test 3311, export 3310), one Opus implementer. Scratch files `dx-*` in the job's `tmp`. Built to the ruling as amended the same evening ("Slice DX — the ruling" above): the branch was first built to the ruling as first written (a shared `common/lib/researchSetup.ts` and the block on every site's page); on the amendment it was reset to `main` `2f09b065` (slice CK merged) and rebuilt, and of that first pass only the READMEs commit was kept (cherry-picked as `7921293f`). **What it does.** - **The homepage's AI and MCP doc has a "Ten-minute setup"** (`homepage/content/docs/ai-and-mcp.md`, an `###` under "## The MCP server", right after its two opening paragraphs): one `sh` block — `git clone https://archilyzer.pages.dev/source/archilyzer.git archilyzer # or the tarball on /downloads/`, `cd archilyzer && pnpm install`, `claude mcp add archilyzer --env TRANSCRIPT_SITE_URL=https://jeralyzer.pages.dev -- pnpm -C "$PWD" --filter yt-dlp-transcript-mcp exec tsx src/index.ts`, `claude # then: /ask what has he said about …` — and seven notes: what you need (Node.js 20.9 or newer, pnpm 9 or newer, Claude Code; no corpus, no yt-dlp, no GPU, nothing hosted; the repo has no `engines` field, so the numbers are README's Requirements table), the source ([git mirror](/source/), [tarball](/downloads/)), **register it as `archilyzer`** with the one-sentence reason, the two optional editor lines for `fetch_clip`, `TRANSCRIPT_SITE_URL` / `TRANSCRIPT_HUB_URL`, the `mcp.json` form for another client (in `mcp/README.md` on the raw tree), and WSL2 on Windows (the README's "Claude Code on Windows", on the raw tree). "What it can do:" became `### What it can do`, same words, so the list is not under the setup. Everything else on the page is unchanged. - **The sites' `/use-with-ai` page is removed** (`export/app/use-with-ai/`, export and hub). Its links keep the label **Use with AI** and go to `AI_DOC_URL` (`common/lib/project.ts`, `${PROJECT_URL}/docs/ai-and-mcp/`), same tab, as plain anchors: the header's nav (its entry, and `MobileMenu`'s, carry `external: true`), the footer's, and the one on Ask AI. - **What named the page names the doc:** `corpus.json`'s `useWithAi` (site and hub; the key stays), `llms.txt`'s "## Ask AI" (two lines now: `[Ask AI](/ask/)` for the chat and `[Use with AI]()` for the MCP setup, site and hub), and the sitemap (`/use-with-ai` dropped). - **The READMEs are their own copies:** `mcp/README.md`'s "Add to Claude Code" and `mcp.json` examples register `archilyzer` (were `rekietalyzer`) from `/ABS/PATH/TO/archilyzer`, the directory the clone makes; its renaming paragraph stays and says why every example uses the name. `README.md` §1 registers with `pnpm -C "$PWD"`, "from the repo's root" (was `/ABS/PATH/TO/this/repo`, against §4 and the Windows notes); §4's quickstart starts from the source (`git clone …` or the tarball, then `cd archilyzer && pnpm install`). The homepage's drift table (`homepage/content/README.md`) names README §1/§4 and mcp/README as copies of the setup's commands, to change together; FACTS says the same. - **Specs.** `first-search` and `restore-no-refire` left the workspace through the header's Use with AI, a client navigation in the same page life. No site link makes one to a page outside the workspace now (the footer's Changelog is a plain ``, a new page life), so they push `/changelog/` through `window.next.router` (FACTS) and assert the Changelog heading and no query builder before Back or the header's Search; their assertions are unchanged. `responsive` checks `/changelog/` in place of `/use-with-ai/`, expected to fail (below). **Commits** | Commit | What | |---|---| | `7921293f` | `docs:` mcp/README registers `archilyzer` in both examples; README §1's `"$PWD"` | | `8bfecf14` | `common:` `AI_DOC_URL`; `corpus.json` `useWithAi`, `llms.txt`'s Ask AI, the sitemap | | `e5160555` | `export:` the page removed; the header's, the menu's, the footer's and Ask AI's links; `first-search`, `restore-no-refire`, `responsive`; `use-with-ai-link.spec.ts` (site and hub) | | `ab8d46a5` | `homepage:` the Ten-minute setup; the drift table; `docs.spec.ts` | | `217d398f` | `docs:` README §4's quickstart starts from the source | | `ae206b01` | `export(e2e)`, `homepage(e2e)`: `/changelog/`'s overflow marked `test.fail`; `docs.spec`'s locator | | `6027b0dc` | `docs:` mcp/README's two examples name the checkout `/ABS/PATH/TO/archilyzer` | | `20605b5f` | `plans:` this section; FACTS; the export and homepage changelogs | | `1fcb224d` | `common:` review L3 — `corpus.test` pins `useWithAi` (site and hub) and llms.txt's two Ask AI lines | | `cadc595e` | `homepage:` review L2 — the doc names `fetch_clip` as the one exception to "it only reads" | | this commit | `plans:` review L1 (FACTS), L4 and the slices table's DX row; the review's findings; STATE's two follow-ups | #### Gates (logs `$T/dx-*.log`) - **tsc** (all workspaces) clean, 70 s — after deleting the worktree's `export/.next/dev/types`, left by the first pass's e2e dev server: its `validator.ts` imported the removed page (the build regenerates `.next/types`; the dev copy waits for a dev server). Export and homepage alone again after the spec fixes: clean. - **common:** 2,380/2,380, 129 s (the count unchanged: `corpus.test.ts`'s sitemap case takes `/changelog` as its sample route). **`test:scripts`:** 194 passed, 2 skipped (the umtool post-build check, whose worktree build predates its code; `LIVE`). **homepage unit:** 23/23. - **Builds:** `pnpm --filter export exec next build` exit 0, 33 s; the hub (`INSTANCE_MODE=hub`, the same command) exit 0, 31 s. In each `out/`: no `use-with-ai/`; every "Use with AI" in the HTML (the header's, the footer's, Ask AI's) has `href="https://archilyzer.pages.dev/docs/ai-and-mcp/"`; no `href="/use-with-ai`. The homepage (`next build`, capped at 5 GB) exit 0, 22 s; the doc carries the block. `eslint` on the touched export files: clean. - **e2e:** see the table below. - **Numbers tool** (`plans/tools/compose-fixture-one-youtube-channel`, composed into scratch): this slice's differences are exactly `corpus.json`'s `useWithAi`, `llms.txt`'s two Ask AI lines and `sitemap.xml`'s `/use-with-ai` entry. The committed fixture had already drifted from `main` (`spec` 3 → 4, four `_headers` entries, `index/sites/testsite/tag-counts.json`), so it was not updated. - **Privacy:** 0 matches of the operator's user name and 0 of the host name in every changed file, but `plans/FACTS.md`'s user-name count of 3, which is `main`'s (0 in the lines this slice adds). | Run | At | Specs | Result | |---|---|---|---| | 1 | `217d398f` | homepage `docs.spec.ts` | 4 passed, **1 failed** (`.doc-measure` matched three elements; fixed in `ae206b01`), 19 s | | 2 | `217d398f` | export `use-with-ai-link`, `first-search`, `restore-no-refire`, `responsive`, `header` | 61 passed, **1 failed** (`/changelog/` overflows; marked in `ae206b01`), 4.9 min | | 3 | `ae206b01` | homepage `docs.spec.ts` | **5 passed**, 0 failed, 19 s | | 4 | `ae206b01` | the full export suite | **271 passed**, 0 failed, 15.1 min (`main`'s 267 + 4; `/changelog/`'s overflow case fails as marked, which the list prints as ✘ and counts as passed) | | 5 | `ae206b01` | the hub suite | **39 passed**, 0 failed, 1.6 min (`main`'s 36 + 3) | #### Found and left - **`/changelog/` overflows a 390 px phone by about 600 px** (the new `responsive` case; it was not checked before). Long inline `code` in released entries (`export/app/ask/{MessageBubble,…}.tsx/ts`, a JSON literal) has no break opportunity. The case is `test.fail`, so a fix turns it red. Fixing it is a change to the changelog's rendering, outside this ruling. - **A multi-line JSX text holding an HTML entity loses its leading space** under Next's SWC (FACTS): 22 texts in 19 files run a word into the element before it — 1 in `homepage/app/downloads/page.tsx`, 21 across `editor/app/**`; none in `export/app` or `common/components`. Found on the first pass's page, which is gone. Not fixed. - **The compose fixture under `plans/tools/` is behind `main`** (above). Refreshing it is its own commit. - `PUBLISH.md`'s registration (`-- pnpm -C "$PWD" archilyzer mcp`) and `AGENTS.md`'s (with the editor lines) register `archilyzer` and were left as they are; `export/CHANGELOG.md`'s released entry for the page is history. - A site deployed before its rebuild still serves `/use-with-ai/` and its old `corpus.json`; the rebuild removes both together. #### Decisions the operator could overturn | What I did | The alternative | |---|---| | `AI_DOC_URL` in `common/lib/project.ts`, beside `INSTANCES_URL` | `${PROJECT_URL}/docs/ai-and-mcp/` written at each of the six places | | The header's and the menu's entries render a plain `` (`external: true`) | `next/link` with the absolute URL, which also renders an `` and does not client-navigate | | `corpus.json` keeps `useWithAi`, now the doc's URL | Drop the key (a contract change) | | `llms.txt`'s Ask AI section: the site's `/ask/` chat and the doc, two lines | One line, to the doc | | The sitemap drops `/use-with-ai` and adds nothing | Add `/ask/` | | `first-search` and `restore-no-refire` reach `/changelog/` through `window.next.router.push` | Click the footer's Changelog: a hard navigation, a new page life, which is not what those tests prove | | `responsive` checks `/changelog/` with `test.fail` | Drop the route; or fix the changelog's wrapping here | | "What it can do:" is `### What it can do`, same words | The setup at the end of "## The MCP server", after the list | | The doc's notes include where the `mcp.json` form is (the removed page carried it) | Leave it out | | README §4's quickstart gains the clone step, with the tarball's host in its comment | Leave §4 starting at `pnpm install` | | The doc's `/ask what has he said about …` (README §4's words) | "they", for any archive | #### Review **Verdict: SHIP AFTER FIXES** (`dx-review.md` in the job's scratch): four lows, no High or Medium. | Finding | Where | |---|---| | L1: FACTS said README §1, §4 and mcp/README "say the same steps" | This commit: README §4 carries the doc's whole sequence in the same order; README §1 and mcp/README share only the registration command (§1 with `"$PWD"`, mcp/README with `/ABS/PATH/TO/archilyzer`) | | L2: the doc brought in `fetch_clip` beside "it only reads already-published static JSON" | `cadc595e`: "The one exception is `fetch_clip`, which asks a local Archilyzer editor for a clip's media: the editor writes it, and the server itself never writes." | | L3: nothing pinned the composer changes | `1fcb224d`: `useWithAi === AI_DOC_URL` for site and hub; the two "## Ask AI" lines of each llms.txt; no `use-with-ai` in either | | L4: the record's base | This commit: off `main` `2f09b065`, first cut at `2b767bc9` | | I1: `window.next.router` is undocumented | This commit: FACTS says so, and to check it first on a Next upgrade | | I2: `test.fail` is satisfied by any failure, not only the overflow | Left as ruled; STATE's follow-up drops the line once the changelog wraps | | I3: the slices table's DX row described the first ruling | This commit: the row states the amended scope | | I4: the primary's `export/.next/{,dev/}types` still name the removed page | The parent's, before the post-merge tsc | | I5–I8 | No action here (deploy order: the homepage with or before the sites and hub; README's Windows blocks' `` a follow-up) | **Follow-ups in `STATE.md`:** `/changelog/`'s overflow (wrap `` in the changelog renderer, then drop the `test.fail`); the JSX entity/whitespace sweep (22 texts in 19 files, FACTS). **Gates after the review** (logs `$T/dx-regate.log`, `$T/dx-e2e4.log`): tsc (all workspaces) clean, 125 s; common **2,381/2,381** (+1), 166 s; mcp **271/271**, 59 s; homepage `docs.spec.ts` **5 passed**, 19 s; export `use-with-ai-link.spec.ts` **4 passed**, 12 s; the hub's **3 passed**, 10 s. The full suites were not re-run, as the parent directed. ### Slice FK, as shipped — a form that fails keeps what was typed (2026-10-01) Branch `r16/forms-keep-input` off `main` `57d982bb`, worktree `~/Projects/r12-paths-fix` (editor 5001, test 5011, export 5010), one Opus implementer. Scratch files `fk-*` in the job's `tmp`. The ruling is above ("Slice FK — the ruling"); the inventory that found the cause is `fk-inventory.md`. **The cause had three more parts than the inventory found, and the slice fixed all of them** (the operator's "fix it everywhere", relayed mid-slice). Measured first in Chromium against `react-dom@19.2.4` (a bundled test page, `fk-exp/`), then in the editor itself on `main`'s code with a throwaway probe spec (`fk-probe-main.keep.log`), each read at 0 / 0.3 / 1.5 / 7 s after the refusal showed: | Element | On `main` after a submit | Why (react-dom; lines in FACTS) | |---|---|---| | Uncontrolled text input, textarea, checkbox | back to the stored value, for good | the reset; no action returned what was typed (the inventory's finding) | | Uncontrolled `` in them goes through the helpers; `state` comes from each form's `useActionState` (ChannelForm's through `ChannelFormClient`). - **Shaped differently, and said where it is:** DigestSettingsForm's `digestSections` is one name posted once per ticked box, which `values` (first value per name) cannot carry; its boxes are controlled (`ControlledCheck`) and re-read from the stored list after a success or a new stored list, posting what they always posted. ChannelForm's edit-mode handling radios seed from the VALUE posted, not the name's presence. - **The shared widgets' checkboxes and selects** (`ae5a212f`, its own commit): the ruling named `DurationField`, `SocialLinksField`, `SiteMembershipsSection` and `WorkersField` as the model that already survives. Their text inputs do; their checkboxes and selects are the last row of the table above. One tag swap each to `ControlledCheck`/`ControlledSelect`, nothing else changed in them. `DigestAppsField`, `LocationForm`, `EditorTagsClient`, `BulkCadenceBar` and `ChannelCadenceEditor` are untouched (the last two get the fix through `DurationField`). - **Inventory check** (the operator's "plus any form the inventory missed"): every `defaultValue=`/`defaultChecked=` under `editor/app` is seeded, and every `` with a field was looked at. The one not changed is the Diagnostics stage's **Go to video** (its action always redirects; there is no refusal to keep anything through). **A success path is unchanged except where it was showing the wrong thing:** a select, and a controlled checkbox or select, now show what was saved after a save; each used to go back to what the page mounted with (the uncontrolled select for good). The `[Unreleased]` bullet says so. **Commits** | Commit | What | |---|---| | `353f3219` | `editor:` `formState.ts` + test, the one `Field`, `SeededInput`/`SeededSelect`, `Controlled.tsx`; SiteForm and SettingsForm with their actions | | `06984433` | `editor:` the other thirteen forms and their actions | | `ae5a212f` | `editor:` `DurationField`, `SocialLinksField`, `SiteMembershipsSection`, `WorkersField` — controlled checkboxes and selects | | `716f58bb` | `editor(e2e):` `forms-keep-input.spec.ts`, 18 cases | | `798cf7ec` | `editor(e2e):` controlled elements read once, as the refusal shows | | `19f8e867` | `editor:` `ControlledCheck`/`ControlledSelect` put the value back after the reset only — never at mount | | `657a6949` | `plans:` this section; FACTS; the editor changelog | | `6cf51b24` | `editor:` review L1 — ChannelForm create's **On create** boxes seed only from a refusal of their own kind; the spec's 19th case; `Controlled.tsx`'s comment (I2) | | `0520ec75` | `plans:` review L1's bullet, I1, I2, I3 | | `9487c74b` | merge of `main` `458f4fcd` (below) | | this commit | `plans:` the review, the gates after it, the merge | #### Gates (logs `$T/fk-*.log`) - **tsc** (all workspaces) clean, 46 s, at `19f8e867` — after deleting the worktree's `export/.next/dev/types`, left by an earlier slice's dev server and still naming the removed `/use-with-ai` page (DX's note). - **common:** 2,404/2,404, 78 s (nothing under `common/` changed). **Editor unit:** 109/109 (`main`'s 101 + `formState.test.ts`'s 8). - **Build:** the capped editor build with the corpus linked (`ln -sT` the primary's `transcripts`, `systemd-run --scope -p MemoryMax=5G`, `pnpm --filter editor exec next build`, the link removed after): exit 0, 43 s, 1.65 GB peak, at `19f8e867` (48 s, 1.6 GB at `798cf7ec`). - **Numbers tool:** none. | Run | At | Specs | Result | |---|---|---|---| | 1 | `716f58bb` | `forms-keep-input` + `sites-crud`, `settings`, `channels`, `storage-locations`, `site-scope` | **75 passed**, 0 failed, 5.2 min | | 2 | `main`'s `editor/app` (`57d982bb`), the spec at `716f58bb` | `forms-keep-input` | 17 failed, 1 passed, 3.4 min — the worker list's boxes were re-synced by a re-render inside the polled assertion's 5 s (so run 4) | | 3 | `main`'s `editor/app`, then the slice's | a throwaway probe (5 cases, not committed) | the table above (`fk-probe-main.keep.log`); on the slice's code every read is right at every moment (`fk-probe-slice.log`) | | 4 | `main`'s `editor/app`, the spec at `798cf7ec` | `forms-keep-input` | **18 failed**, 0 passed, 2.5 min — every case fails on the code before the slice | | 5 | `798cf7ec` | the full editor suite | stopped at 203 of 694: `cadence-ui` (3) and `channel-site-membership`'s create case failed — the first `Controlled.tsx` overwrote a choice made before hydration; fixed in `19f8e867` | | 6 | `19f8e867` | `forms-keep-input`, `cadence-ui`, `channel-site-membership`, `workers`, `operation-settings`, `saved-videos`, `new-channel-onboarding` + the five the prompt named | **109 passed**, 0 failed, 6.9 min | | 7 | `19f8e867` | the full editor suite | **681 passed**, 1 failed, 12 skipped (the rack-audit shots), 38.1 min — `export-search` "Advanced reset does not touch filter checkboxes": its `getByRole('checkbox', { name: 'Deleted' })` also met a result's "Select "Deleted platypus chronicles" for AI" box | | 8 | `19f8e867` | `export-search.spec.ts` alone | **19 passed**, 0 failed, 50 s. Nothing under `export/` or `common/` differs from `main`; run 7's failure is that locator's race with the results | #### Found and left - **A controlled NUMBER input focused when Enter submits** (`DurationField`'s amount, the number boxes in `WorkersField` and `DigestAppsField`) shows its old value after the reset until its next change: React writes its `value` on an update but nothing updates it. What posts is from state (a hidden input), so nothing saved is wrong. A `ControlledInput` would close it; not built. - **An action that throws past its own `try`** (`createChannel` itself, for one) still reaches the form as Next's error, not as `{ error, values }`. Every caught failure carries `values`; converting the uncaught ones is a behaviour change of its own. - **A field not rendered at the submit** is seeded as not posted: a checkbox reads unticked when a later render shows it, a text field keeps its initial value. The one place a form switches what it renders is ChannelForm create (a social or a video source), and its **On create** boxes seed only from a refused submit of their own kind (review L1, below); every other box a social submit leaves out starts unticked in create mode anyway. - **A seeded select or number input re-mounts when its stored value changes under it** (a save from another tab), and an in-progress edit of THAT field is lost; every other field keeps its edit. DigestSettingsForm's section boxes do the same: they re-read the stored list whenever it changes and the last result was not a refusal, before any submit too, so unsaved ticks give way to another tab's save (on `main` a touched box kept its tick). - **`ControlledSelect` puts back a value no option has as a blank select** (`selectedIndex` −1); React's own update selects the first enabled option instead. Reachable for `WorkersField`'s engine select (`row.appId ?? ""`, no `""` option) and a membership whose group was deleted. Visual only: both post from state. - **The other way to do it**, for the record and not tried: `onReset={(e) => e.preventDefault()}` on each action form should cancel React's reset outright, so nothing is wiped and nothing needs seeding, but the success path would keep what was typed instead of re-reading the stored values. The ruling chose the values contract; this was not built. #### Decisions the operator could overturn | What I did | The alternative | |---|---| | `seedValue` keeps `initial` for a name the failed submit did not carry | The prompt's `values[name] ?? ""`: a disabled or not-yet-rendered field would go blank (the ruling's "else from the initial value" read per field) | | Fixed the controlled checkboxes/selects too, including in the four widgets the ruling named as the model (`ae5a212f`, revertable alone) | Leave them: their desync lasts until the next re-render, and the unnamed ones post from state | | `SeededSelect` re-mounts on every change of its default, a success included | Re-mount only out of a failure: the select would keep showing its mount-time option after a plain save | | DigestSettingsForm's sections controlled | Extend `FormValues` to carry every value of a repeated name | | The e2e refuses the settings-only forms by putting a directory at `test-settings.json` | A test-only failure switch in `saveSettings` behind `/api/test/*` | #### Review **Verdict: SHIP** (`fk-review.md` in the job's scratch): one Low, five infos. | Finding | Where | |---|---| | L1: in ChannelForm create, after a refusal from one kind of source (video or social), switching the URL to the other kind mounted that kind's **On create** box seeded from `values`, where its name was absent, so unticked; on `main` it mounted ticked | `6cf51b24`: `fetchPostsNow` seeds only from a refused social submit (it posts the hidden `sourceKind=social`), `fetchPlaylist` and `prioritizeDownload` only from a refused video one. A 19th case refuses a video submit and switches to a social URL (**Fetch posts now** ticked), and the reverse; against `657a6949`'s ChannelForm it fails ("Received: unchecked") | | I1: the digest section boxes re-read a new stored list before any submit too, so another tab's save replaces unsaved ticks | `0520ec75`: added to the cross-tab bullet in "Found and left" | | I2: a controlled select never gets `defaultSelected` (mount passes `false` too, `:22121`); the reset selects the server-rendered option, or the first for a client-mounted select — not "what it mounted with" | `6cf51b24` (`Controlled.tsx`'s comment), `0520ec75` (FACTS, the table above) | | I3: `putValue` leaves a blank select for a value no option has | `0520ec75`: recorded in "Found and left" | | I4: the microtask deferral relies on React's script-initiated reset; a native `