Archilyzer · Source

archilyzer

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

commit 3a349be6fcdc1b9c45146943cf75601a3af33814
parent 42dd30007b8f064c73ad06503f3a167f7195c245
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Thu,  1 Oct 2026 20:51:24 -0400

plans: slice XP, as shipped — X posts are private; the ruling, the slices row, FACTS, the editor changelog

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Diffstat:
Meditor/CHANGELOG.md | 2++
Mplans/FACTS.md | 16+++++++++++++++-
Mplans/release-17.md | 191+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 208 insertions(+), 1 deletion(-)

diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md @@ -20,6 +20,8 @@ - **A form whose save is refused keeps what you typed.** Every editor form put its plain fields back to the stored values when its save was refused — a site's ID rejected, a page size out of range, a slug already taken — so everything typed had to be typed again. A refused save now leaves every field as you left it, beside the reason: **Settings**; a site's form (new and existing); the hub's config on `/sites`; **Cut release**; a channel's form (new and **Configure**), **Rename** and **Delete**; a video's **Delete directory**; **Drive health timing** on `/storage`; the backup config on `/saved-videos`; the sync operation's controls; the **Digest**, **Diarization**, **Speaker attribution** and **Speaker work lane** settings; and the worker list on `/workers`. A save that succeeds behaves as before, with one difference you may notice: a drop-down, and a checkbox or choice that the page tracks as you change it (a cadence, a worker's **Enabled**, a social link's **Keep in header**, a site membership, a site's accent), now shows what was saved. A form's own drop-downs used to go back to what the page had loaded with until a reload, and a second save from the same page sent that old choice again; the others went back until the page next refreshed itself (every 5 seconds by default). - **A media move no longer starts over a job that is writing into the channel, holds the channel's writers while it runs, and makes its copy match the source before it verifies — so a transcription or a download during a move cannot fail it.** A move that has waited its turn behind other moves now checks again when it starts: if a job is running on the channel, or an auto-queue lane is working on one of its videos, it stops at once and says which ("a transcription of abc123 is running (Transcribe all, job …) — wait for it or cancel it"), with nothing copied — and a job you have just cancelled counts until it has actually stopped ("is stopping … — wait for it to stop"); **Preview** says the same, and the Storage panel's blocked message now names the job too. While a move's marker stands, the channel is held: every lane skips it, and every job that reads or writes its media (single-video transcriptions, downloads and transcodes and the availability checks now included) refuses to start, including one that was already queued when the move began. The rack shows a **media held** chip in the channel's Tier cell and the Storage panel says "Held: its media is moving"; both go when the move finishes or its marker is cleared. The copy is now followed by a pass that makes the destination copy match the source — files the source no longer has are removed from the copy, never from the source — so a file written or deleted during the copy (a transcriber's scratch folder, say) no longer fails the check, and **Resume move** finishes a move whose copy holds such leftovers. Every file removed from a copy is listed in the move's log, and **Preview** says so when a copy from an earlier attempt is already there. If the source keeps changing, the move stops and lists what differs: extra on the destination, missing there, or changed. A new **Reconcile and resume** button beside **Resume move** lists those differences, makes the copy match and finishes the move, so no file has to be deleted by hand. The saved-video store's move does the same matching and the same check before it starts. Needs a rebuild and restart of the editor. - **Connecting an X account opens your own browser, and the X fetchers can use your everyday browser's X login instead.** **Settings → X account session → Connect X account** used to open Playwright's bundled Chromium with its automation signals on (the "controlled by automated test software" bar, `navigator.webdriver`): Google's sign-in refused it and X's own login form stalled in it. It now opens your Chromium or Chrome when one is installed (`ARCHILYZER_X_BROWSER` names another; Playwright's bundled Chromium otherwise), without those signals. Google's sign-in may still refuse an embedded browser; X's password login is the reliable path. A new **Login source** choice (`social.x.cookieSource` in `settings.json`) says where the X fetchers' login comes from: **Browser login** hands gallery-dl `--cookies-from-browser` with your `cookiesFromBrowser` on every fetch, so the login lasts as long as you stay logged in to x.com in that browser and no window is needed; **Connected profile** is the session broker, as before. Left on **Automatic**, it is the browser login when `cookiesFromBrowser` is set and no profile is connected, and the profile otherwise. **Check** says which source is in use, whether an X login is visible in it and when it was last used (the browser's cookies are read from a private copy, never written; this reads Firefox's, and gallery-dl reads Chromium's itself). Needs a rebuild and restart of the editor. +- **X posts can be kept off every public site.** **Settings → X account session** has a new choice, **Where X posts appear** (`social.x.visibility` in `settings.json`): **Public**, the default, builds an X channel's posts into every site that has the channel, as before; **Private** leaves every X channel out of every public site's build — its posts, its posts manifest entry and its place in the channel list, `site.json` and `corpus.json` — and builds it only into private sites (below). Nothing on disk changes and fetching goes on. A site already published changes on its next build and deploy, and a public site whose only posts were X posts loses its **Posts** box under **Search in**. Choosing the X login source no longer forgets this choice, and choosing this one keeps the login source. +- **A site can be private: built for reading on this machine, never deployed and never listed.** A site's settings have a new **Audience** choice (`audience` in `site.json`; only `"private"` is written). A private site is refused by every deploy — **Build & deploy**, **Deploy**, `archilyzer deploy site`, `pnpm ops build-deploy` and `deploy-site`, **Build & deploy all** (which builds it and skips its deploy) and `docker/publish-site.sh` — before anything is uploaded, in a sentence naming the audience; **Build** still builds it. It is left off the homepage, the hub and every other site's footer whatever **List on the Archilyzer homepage and hub** says, it publishes no hub URL, and its `corpus.json` says `"audience": "private"`, so a build of it is refused too if the site is switched back to public before it is rebuilt. Point the MCP at a private site's build to ask about what only it holds. ## [0.11.0] - 2026-09-30 - **Transcripts that arrived after a video was first seen are counted.** The stats behind the homepage, the hub and every site's charts were cached per video and refreshed only when the video's metadata changed, so a transcript that came later — a Whisper run days after the download, or a video downloaded after the last index build — never reached them, and a video with YouTube captions alone had no transcription date. Counts and charts were low; the homepage could show a site with 0 transcripts, 0 channels and 0 hours while it served its videos. A stat is now also redone whenever the index re-reads the video, every transcript has a date, and a captioned video is dated by when its captions arrived rather than by a later Normalize run, so its place on "Transcribed over time" can move. **After updating, rebuild and restart the editor before anything else:** until then, **Build stats dataset** runs the old code and would undo the new stats, while a site, hub or homepage build already runs the new code — and the first stats build of any kind re-reads every video once (about 10–30 minutes on a large archive; it can be stopped and picks up where it stopped). Then build the index, the stats, the homepage, the hub, and the sites. diff --git a/plans/FACTS.md b/plans/FACTS.md @@ -165,7 +165,7 @@ Never name the curated field `tags`. Never assume a `tags.json` is the keyword l | --- | --- | --- | | A video's visibility | `common/lib/availability.ts` (the `"unlisted"` state, `isUnlisted`), `common/lib/transcripts{,-server}.ts`, `common/components/shareUrl.ts`, `common/controller/buildIndex.ts` | The platform's own "unlisted" (reachable by link, not listed on the channel). | | The hub's list has loaded | `export/app/components/hub/useHubSites.ts` — `listed` | `/hub-sites.json` has been answered and `/hub-summary.json` has settled. | -| **A site the family lists** | `site.json` `listed` (`common/lib/siteSchema.ts` — `isListedSite`, `channelsOnlyOnUnlistedSites`) | Absent = listed; `false` keeps the site off the homepage, the hub and the other sites' footers, and out of the public totals. | +| **A site the family lists** | `site.json` `listed` (`common/lib/siteSchema.ts` — `isListedSite`, `channelsOnlyOnUnlistedSites`) | Absent = listed; `false` keeps the site off the homepage, the hub and the other sites' footers, and out of the public totals. A PRIVATE site (`audience: "private"`, release 17 XP) is never listed, whatever `listed` says. | A grep for either word finds all three; read the file before assuming which. @@ -8251,6 +8251,20 @@ phase deletes from the destination. **The source, not `cookieMode`, governs the X fetchers**: the browser source passes the spec whatever the mode; the profile source keeps the old order (the jar, else the `"always"`-mode spec, else a guest run). +- **Where X posts may appear, `social.x.visibility`** (release 17 slice XP; `"public"` default | + `"private"`, kept by `sanitizeSocial` beside `cookieSource`). **saveSettings' merge is one level + deep, so a patch `{ social: { x } }` replaces the whole X block**: both X actions + (`xSessionActions.ts`) write over the block as it is (`socialXPatch`). The rule is + `common/lib/postsVisibility.ts` (`postsVisibleTo`, `publishedMemberSlugs`): while private, an X + channel (social, platform `twitter`) is built only into sites with `site.json` `audience: + "private"`; a public site leaves the channel out WHOLE (posts are all it holds) — its posts + manifest entry, posts tree, transcripts tree, channel list, `site.json` and `corpus.json` entry, + and `channel-sites.json`. Applied in `buildIndex`'s per-site loop (the site fingerprint names the + `withheld` members) and in `compose-site` (which prunes a previously shipped tree); the shared + posts tree and the LMDB posts sub-DB stay corpus-wide. A private site publishes no `hubUrl` + (`resolveHubUrl`), its `corpus.json` says `site.audience: "private"`, and every deploy path + refuses it or its bundle before any upload (`lib/builtExport.ts` `deployAudienceProblem`; the + bulk deploys skip it). Build & deploy all still BUILDS it. - **gallery-dl 1.32.9** takes `--cookies-from-browser BROWSER[/DOMAIN][+KEYRING][:PROFILE][::CONTAINER]` (yt-dlp's syntax plus `/DOMAIN`) and reads every browser it supports, Chromium's encrypted store included, on each run. The spec is passed verbatim (`galleryDlCookieChoice`). diff --git a/plans/release-17.md b/plans/release-17.md @@ -263,6 +263,7 @@ one short Transcribe (the hook on a relocated channel), `/storage`, `df`; then n | **T3** migration + records | `r17/media-tier-migrate` | `common/bin/migrate-media-tier.ts`, `archilyzer.ts` wiring, fixture tests (tmp "platter"), FACTS "A channel's media is tiered", AGENTS.md's six things → seven, SETTINGS.md/CHANNEL.md regen, the release record, changelog | T1, T2 | dry run; resume from each phase; idempotent rerun; `--reclaim`; refusal on a marker; the free-space stop | | **U1** umtool roots + `out/` | `r17/umtool-media-root` | `paths.mjs` (`MEDIA_ROOT`, `CACHE_DIR`), `lib/report/storage.mjs`, `driver.mjs`, `build-video.mjs:2615`, `export.mjs`, `kinds.mjs`, `umtool doctor`, `umtool storage move-out`, the e2e env | — (∥ T1) | `test:scripts` (+ mover tests), `next-build-trace.test.mjs`, the capped umtool build with the corpus linked, umtool e2e | | **U2** deliverables switch | `r17/umtool-deliverables` | manifest `storage` field, `deliverableDir`, `cut.mjs:96`, `deliver.mjs:362`, `umtool storage deliverables`, bench "Move deliverables", `umtool check` | U1 | umtool unit + e2e: cut and share through a linked `clips/` | +| **XP** X posts are private (operator-requested, beside the media tier) | `r17/x-posts-private` | `common/lib/postsVisibility.ts` (new) + test, `settingsSchema.ts` + `social/xCookieSource.ts` (`social.x.visibility`) + SETTINGS.md, `siteSchema.ts` + `site.ts` (`audience`, `isListedSite`, `resolveHubUrl`) + SITE.md, `buildIndex.ts` (the per-site loop only), `bin/compose-site.ts` + an integration test, `lib/corpus.ts`, `lib/builtExport.ts`, `publish/build.ts` + tests, `controller/poolSummary.ts` + test, `docker/publish-site.sh`, `export/app/offline/page.tsx`, editor `settings/{xSessionActions.ts,page.tsx,components/{XSessionSection,XPostsVisibilityControl}.tsx}`, `sites/{actions.ts,components/SiteForm.tsx,lib/{buildAction,deployAction}.ts}`, e2e `x-session`, `sites-crud`, export `x-posts-private` | — (∥ all) | the compose integration test (public vs private site, flip back, an X-only public site); deploy refusals before wrangler and before the upload | Order: 0a → D0 ∥ T1 ∥ U1 → T2 ∥ U2 → T3 → parent: records, ONE editor rebuild + restart, umtool rebuild + restart (the restart is the operator's: the permission layer refuses the `0.0.0.0` bind) → the migration @@ -321,6 +322,196 @@ hand; a dirent `isFile()` filter over a video dir hides it."** The `.relocating. - The `en` track → 0 cues bug (index prefers `en` over `en-orig`; some `en` VTTs parse to 0 cues). - A channel export/import **bundle** built on `mediaTier.ts`'s classifier — the slice after this release. +## Slice XP — the ruling (2026-10-01) + +- **Every X post is hidden from the public, for now; the data is kept, and stays readable by the MCP + and umtool for the operator's own questions and tasks.** Fetching is not changed by this slice. +- **A setting, `social.x.visibility`: `"public"` (default) | `"private"`**, beside + `social.x.cookieSource`, chosen on `/settings` in the X account session section as "Where X posts + appear", with one sentence saying what private means and that sites already published change on + their next build and deploy. `"private"`: every X channel's posts (`sourceKind: "social"`, + `platform: "twitter"`) are left out of every PUBLIC site build and built only into PRIVATE sites. + Nothing on disk changes; flipping back is a rebuild. +- **A site audience, `site.json` `audience`: `"public"` (default, absent) | `"private"`**, on the + site's form with a sentence. A private site is **never deployed** — every deploy path refuses it + with a sentence naming the audience, before any upload, where the release 13 W3 wrong-site guard + runs; a build-only still works — and **never listed**: no `hubUrl`, in no homepage or hub listing. + Its `corpus.json` says `"audience": "private"`. +- **One predicate, `postsVisibleTo(site, channelConfig, settings)`**, pure and tested in + `common/lib`, called from the index build and from compose. The Search in row's Posts toggle keeps + working from what the build shipped (a public site whose only posts were X posts has no posts + corpus and no Posts toggle) — verified, not special-cased. The MCP needs no change; umtool's report + pipeline is checked for where it reads posts. +- Not in scope: stopping fetches; a per-platform toggle for Bluesky; deleting anything; editing + `transcripts/**` (the rollout — a private site holding every channel, the setting flipped, the + public sites rebuilt — is the parent's, through the editor's own writers). + ## Record +### Slice XP, as shipped — X posts are private (2026-10-01) + +Branch `r17/x-posts-private` off `main` `90bd8384`, worktree `~/Projects/r13-lows-export` (editor 5501, +test 5511, export 5510), one Opus implementer, beside the media-tier slices. Scratch files `XP-*` in the +job's `tmp`. The ruling is above ("Slice XP — the ruling"). + +**The listing side is release 14 slice HS's.** HS shipped `site.json` `listed` (absent = listed), the +one predicate `isListedSite`, and every listing that reads it: the homepage summary, +`channel-sites.json`, the pooled stats, the hub's `hub-sites.json` (and so its `corpus.json` and +`llms.txt`), every footer, and the form's **List on the Archilyzer homepage and hub** checkbox. This +slice adds no listing plumbing of its own: `isListedSite` gains one clause — a private site is never +listed, whatever `listed` says — and the checkbox stays as it is. What is new is the deploy refusal, +the private site's empty `hubUrl` and its `corpus.json` word, and "private content is built only into +private sites". + +**What it does.** +- **`social.x.visibility`: `"public"` (default, absent) | `"private"`**, beside `social.x.cookieSource` + (`XSocialSettings` and `sanitizeSocial` in `common/social/xCookieSource.ts`, the social block's home; + its doc in `settingsSchema.ts`; SETTINGS.md regenerated). On `/settings`, at the foot of the X account + session section, **Where X posts appear** (`XPostsVisibilityControl.tsx`): "Public — every site that + has the channel" | "Private — private sites only", with: "Private leaves every X channel and its posts + out of every public site and builds them only into sites whose audience is private, which are never + deployed; nothing is deleted and fetching goes on. Sites already published change on their next build + and deploy." Written by `setXPostsVisibilityAction` through `saveSettings`; "public" is written as no + key. +- **`site.json` `audience`: `"public"` (default, absent) | `"private"`** (`siteSchema.ts`, only + `"private"` written, `isPrivateSite` the one predicate; SITE.md regenerated). The site form has an + **Audience** select with a sentence; `saveSiteAction` keeps only `"private"`. +- **The rule, `common/lib/postsVisibility.ts`** (pure, tested): `postsVisibleTo(site, config, settings)` + — an X channel (`sourceKind: "social"`, `platform: "twitter"`) goes only to a private site while the + setting is private; every other channel is untouched — and `publishedMemberSlugs`, a site's members + narrowed by it. **A public site leaves the X channel out whole**: posts are all a social channel holds, + so without them it would be an empty checkbox and a name in `corpus.json`. Narrowed by the same call in + `buildIndex`'s **per-site loop** (the summaries, subs, posts and digests manifests; the site + fingerprint gains `withheld`, only when non-empty, so flipping the setting or the audience rebuilds + the site and nothing else moves) and in `compose-site` (every shared tree it copies, so a tree a + public site shipped before is pruned). The shared posts tree and the LMDB posts sub-DB stay + corpus-wide. `channel-sites.json` maps a channel only to the sites whose build carries it + (`channelSitesOf` takes the narrowing) and the export's `/offline` page lists the same members. +- **A private build says so and belongs under no hub**: `corpus.json`'s `site.audience` is `"private"`; + `resolveHubUrl` gives a private site no `hubUrl` (absent from its `site.json` and `corpus.json`). +- **Never deployed.** `lib/builtExport.ts`: `siteDeployProblem(site)` — `Site "x" is private (audience: + private): it is built for reading on this machine and is never deployed. Build it without deploying, + or set its audience to public on its Settings tab` — `builtAudienceProblem(outDir)` (a bundle whose + `corpus.json` says private, so a site switched back to public cannot ship its private build) and + `deployAudienceProblem`, both. Asked first, before any upload, where release 13 W3's + `builtBundleProblem` is asked: `runDeployIntoLog` (the last word before wrangler), + `runDockerDeployAllPhase` (skipped with the sentence, not failed, so Build & deploy all is not red + while a private site exists — the site is still built), `deploySite` (`archilyzer deploy site`, before + the R2 upload), the editor's `deployExportAction` and `buildAndDeployAction` (before a job exists; + Build & deploy asks again before its upload), the host Build & deploy all fallback + (`basicBuildAndDeployAll`, skipped before the upload), and `docker/publish-site.sh` (before building, + from `site.json`, and over the built `corpus.json` before publishing). The ops routes `build-deploy` + and `deploy-site` answer the actions' sentence (400). **Build** still builds a private site. +- **The Search in row's Posts toggle**: no code changed. `hasPostsCorpus` is "the site posts manifest + lists a channel", so a public site whose only posts were X posts ships `channels: []` and no Posts + box — pinned by the integration test (no `postScheme` either) and the export spec. + +**Where the rule lives — one deviation.** The prompt named the social-channel branch of `scanSource` +(`buildIndex.ts:333-341`). That branch is corpus-wide: it feeds the shared posts tree that every site, +and the MCP over a private build, read, so it must keep building X posts. The rule sits in the per-site +loop (`buildIndex.ts` ~1943 and the fingerprint), a different hunk from T1's `scanSource` guard. + +**Found on the way, fixed here.** +- **`saveSettings` merges one level deep, so a patch `{ social: { x } }` replaced the whole X block**: + choosing a login source would have erased the visibility, and the reverse. Both X actions now write + over the block as it is (`socialXPatch`); the e2e case pins both directions. +- **compose-site trusted its per-site cache after ANOTHER site's compose.** `public/` is one directory + every site composes into in turn (the basic build); the cache is per site, and a stage whose source + had not changed was skipped. So composing site B, then site A again with no new data, shipped B's + summaries — its whole channel list — as A's: a public site composed after a private one holding every + channel would have listed the private site's channels. The site's own stages (summaries, stats, + duplicates) are now trusted only when `public/site.json` names the site; `site.json` is cleared at + the start of a compose and written at its end, so a compose cut short leaves nothing to trust. The + per-channel trees keep their signatures (copies of the shared trees, the same bytes whichever site + copied them). Docker builds have a per-site `public/` and were not affected. +- **compose never ships a posts tree the site's posts manifest does not list** (the index build's + word), so a channel config compose fails to read — read as visible — does not ship X posts. + +**The MCP and umtool.** +- **The MCP needs no change**: it reads a composed export (`mcp/src/sources.ts`, `--local <dir>` | + `TRANSCRIPT_LOCAL_DIR`). A private site is composed into a directory of its own, without touching + `export/public`, from the checkout root: + ```sh + pnpm archilyzer index # or any editor build: the index build writes the per-site manifests + BUILD_ARCHIVES=0 EXPORT_PUBLIC_DIR="$HOME/archives/<private-id>" \ + EXPORT_INDEX_DIR="$PWD/export/.export-index" pnpm archilyzer compose site <private-id> + claude mcp add archilyzer -- pnpm --silent -C "$PWD" archilyzer mcp --local "$HOME/archives/<private-id>" + ``` + (`EXPORT_PUBLIC_DIR` must be absolute — the command runs in `common/`; the compose cache lands beside + it, in `$HOME/archives/.compose-cache/`.) The site's editor **Build** works too: it composes into + `export/public` and builds into `export/out`. **The current registration, `--local + <checkout>/export/public`, reads whatever site was composed there last**: after a public site's build + it has no X posts, after the private site's it has them. +- **umtool is unaffected**: the report pipeline's posts (the deck's posts room) are carried whole in the + report manifest — `posts[]` with `platform`, `date`, `text`, `url` (`validatePosts`, + `umtool/report-to-video/deck.mjs`), "added by editing the manifest" — and its cues come from the local + corpus or the archive `provenance.siteOrigin` names (videos only). It reads no public build's posts. A + sweep that looks posts up for a manifest goes through the MCP, pointed at the private build as above. + +**Commits** + +| Commit | What | +|---|---| +| `3ea76853` | `common:` `postsVisibility.ts` + test; `social.x.visibility`; `site.json` `audience` (`isListedSite`, `resolveHubUrl`); the per-site loop and compose narrowed; `corpus.json` `audience`; the deploy refusals (`builtExport`, `publish/build`) + tests; `channel-sites.json`; `/offline`; `publish-site.sh`; SETTINGS.md, SITE.md; the compose integration test | +| `0195d52a` | `editor:` Where X posts appear; the X block written whole; the Audience select; the deploy actions refuse a private site | +| `75ccbef3` | `editor(e2e), export(e2e):` `x-session` and `sites-crud` cases; `export/e2e/x-posts-private.spec.ts` | +| `30c14aa0` | `common:` compose never ships a posts tree the index withheld; the cache trusted only over the site's own last compose | +| `63002de3` | `common:` the per-channel trees keep their signatures across sites | +| this commit | `plans:` this section, the ruling, the slices row; FACTS; the editor changelog | + +#### Gates (logs `$T/XP-*.log`) + +- **tsc** (all workspaces) clean before every commit; last at `63002de3`'s tree (`XP-tsc5.log`). +- **common:** **2,499/2,499** at `63002de3`, 161 s (`main`'s count + the new `postsVisibility.test.ts` + 7, `compose-site.postsVisibility.test.ts` 4, `build.test.ts` +3, `poolSummary.test.ts` +1); 2,498 at + `75ccbef3`. `archilyzer docs files --check` and `settings example --check` exit 0. **Editor unit:** + 109/109. **Export unit:** 98/98. **Homepage unit:** 23/23. **mcp:** 271/271 (no change there). +- **test:scripts:** 367 passed, 1 failed, 2 skipped of 370 (`XP-scripts2.log`; the first run had 2 + failed). The failures are `scripts/queue-lock.test.mjs`'s "prints a banner naming the holder while + waiting" (and once "serves waiters in arrival order"): timing cases (200 ms and 150 ms staggers) run + at a load average of 23–30 while other suites held the e2e queue; alone, the banner case still fails + under that load (10/11). This slice does not touch `scripts/` (`git diff 90bd8384 -- scripts/` is + empty). +- **Builds** at `75ccbef3` (the later commits touch only `compose-site`, a bin no Next app bundles): the + capped editor build with the corpus linked (`ln -sT`, `systemd-run --scope -p MemoryMax=6G`, the link + removed after) exit 0, 172 s; `pnpm --filter export exec next build` exit 0, 83 s (the `export/public` + links made, 0 dangling); `pnpm --filter homepage run build:nodata` exit 0, 47 s. +- **Numbers tool:** none. **Privacy gate:** `git diff main --name-only | xargs grep -lc …` names one + file, `plans/FACTS.md`, whose 3 matches are all on `main` already; 0 in this slice's added lines. + + | Run | At | Specs | Result | + |---|---|---|---| + | 1 (editor) | `75ccbef3` | `sites-crud`, `settings`, `x-session`, `forms-keep-input`, `deploy-page`, `site-publish-preview`, `sites-homepage` | **62 passed**, 0 failed, 4.0 min (after 35 min in the queue) | + | 2 (export) | `63002de3` | `x-posts-private` (new, 2), `search-in`, `posts-search` | **20 passed**, 0 failed, 3.0 min (after 7 min in the queue) | + + New cases: `x-session` "where X posts appear persists, beside the login source and without it" (the + whole-block write both ways); `sites-crud` "a private site saves its audience, and every deploy + refuses it before any job" (the form, the file, `build-deploy` and `deploy-site` answering the + sentence with 400, back to public with the key gone); export `x-posts-private` — the posts manifest a + public site built with X private ships (no channel: no Posts box, no X post for "kappa") and the one a + private site ships (the Posts box, both X posts). **The export suite's data is route-mocked, never + built**, so the build rule itself is proved by `common/bin/compose-site.postsVisibility.test.ts` + through the real `buildIndex` and compose: a public and a private site over one video, one X and one + Bluesky channel; the flip back; a public site whose only posts were X posts (empty manifest, no + `postScheme`); a config compose cannot read; a public site composed after the private one. + +#### Found and left + +- **An X channel's manifest-only transcripts tree** (pageCount 0) would still be copied into a public + site when compose fails to read the channel's config: a directory named for the channel, holding no + content. The posts tree, the posts manifest, the channel list and `corpus.json` follow the index build + and do not carry it. +- The `/sites` list does not mark a private site; its form and every deploy refusal do. +- `queue-lock.test.mjs`'s two timing cases failed under the machine's load (below); not this slice's file. + +#### Decisions the operator could overturn + +| What I did | The alternative | +|---|---| +| A public site leaves an X channel out WHOLE (posts, posts manifest, channel list, `site.json`, `corpus.json`, `channel-sites.json`) | Keep the channel listed with no posts: an empty checkbox and a name in `corpus.json` | +| Build & deploy all builds a private site and SKIPS its deploy, with the sentence | Fail its deploy: the run goes red every time while a private site exists | +| A bundle whose `corpus.json` says private is refused even when the site is public now | Trust the site's current audience only (a stale private build could ship) | +| `docker/publish-site.sh` refuses a private site (the `site` service is the host's public face) | Let it publish locally behind Caddy | +| `social.x.visibility` lives in `xCookieSource.ts` with the rest of the social block | A module of its own | + ## Rollout