# Release 12 — the source mirror: a read-only git clone on the project site Written 2026-09-27 in plan mode, RE-CHECKED 2026-09-28 against `main` `90c7f776` (128 commits later; see "What changed since the plan was written"). Self-contained for a fresh session. The design was verified by three Explore passes and one Plan pass; the dumb-HTTP clone, the filter-repo scrub and wrangler's upload filter were PROVED in scratch. Rules: `plans/tools/implementer-rules.md` (one Opus implementer per slice in a worktree, one read-only Opus review, the parent merges). Two slices, Q then R. Record file: `plans/release-12.md` (NEW, the shape of `plans/release-11.md`). Memory to read first: `release-11-overnight`, `fetch-clip-shipped`, and the audit at `~/reports/repo-mirror-audit/AUDIT.html`. ## What changed since the plan was written (re-check 2026-09-28) - **"Release 11" is taken**: the overnight of 2026-09-28 merged slices O1–O6 (+ O2b, O1c, O6c) to `main`, NOT rolled out (owed by the operator: cut, :3001 restart, a settings save, the deploys; runbook `~/reports/overnight-2026-09-28/MORNING.html`). This work is **release 12**, slices Q and R. - `DEPLOY_CLOUDFLARE.md` is GONE, absorbed into **`PUBLISH.md`** (`## Cloudflare Pages` :70, `## Download archives and R2` :137). Every doc edit this plan aimed at it goes to `PUBLISH.md`. - **Env vars are a registry**: `common/lib/envVars.ts` declares every variable the code reads, a test fails on an undeclared read, `ENVIRONMENT.md` is GENERATED (`pnpm archilyzer docs env`, `--check` in the gates), and path overrides are read by `getPaths()` (`common/lib/paths.ts`) and nowhere else. Test-only variables carry an `E2E_` prefix and are declared in the playwright config. - **`archilyzer doctor`** exists (`common/bin/doctor.ts` + test) — slice R adds its checks there. - **The homepage builds from `/sites` now** (release 11 O4): `editor/app/sites/lib/ homepageDeployActions.ts:51,95` call `buildHomepage` IN-PROCESS, and `pnpm ops build-homepage` reaches them. So the source step also runs inside the editor's process and its PATH. - Anchors that moved: `buildHomepage` is `common/publish/build.ts:973`; `WORKTREES.md:119`; `umtool/components/BuildChain.tsx:147-149`. The umtool path literals are where they were. - Baselines now: common **2,112**, editor unit 85, `test:scripts` **185 + 1 skip**, mcp 269, homepage unit 2, homepage e2e **31** (specs: brand, docs, downloads, instance-colours, marketing, no-data, stats, theme). - Still true: 1,985 lines with `/home/user` outside `plans/`, 20 `umtool/song/*.sh`, `/run/media/user` in five `common/` files AND one line of `editor/CHANGELOG.md`; `create-archives.sh` exists and nothing calls it; pipx and gitleaks installed, git-filter-repo not; `~/.config/archilyzer/` absent. ## Working alongside the parallel session (operator's instruction, 2026-09-28) - **Nothing is edited in the primary checkout.** Each slice gets its own worktree from `pnpm wt add`: `r12/paths-fix` → `/home/user/Projects/r12-paths-fix`, then `r12/source-mirror` → `/home/user/Projects/r12-source-mirror` (branched after Q merges). Seven `r11-*` worktrees exist and are the operator's to remove; do not touch them. - **This plan enters the repo on the slice branch**, as `plans/source-mirror.md` in Q's first commit — never as a direct commit to `main`. - **Merging into `main`** happens in the primary with one command, only when `git status --short` is empty and no merge is in progress: `git merge --no-ff r12/`. If the tree is dirty (the parallel session is mid-change), wait and re-check; never stash or touch its files. Expected conflicts: `editor/CHANGELOG.md` `[Unreleased]` and nothing else (the record file is new). - Scratch: `$CLAUDE_JOB_DIR/tmp`, files prefixed `q-` / `r-`. e2e is queued machine-wide. ## Context The operator wants the repo mirrored on the project site (https://archilyzer.pages.dev, the `homepage/` Next.js 16.2.3 static-export app on Cloudflare Pages) as static, read-only files. The 2026-09-26 pre-publication audit found no secret and no personal identifier in the 1,648-commit history except the Unix username in `/home/user` paths (a first name) — plus, found during design, `/run/media/user` in a few `common/` tests and two backticked `user` mentions in `plans/`. Today the site ships only a hand-run, stale (2026-08-12) `git archive` tarball (`create-archives.sh`, whose header says "no public git remote, by choice" — the policy this release reverses on the operator's word). Outcome: `git clone https://archilyzer.pages.dev/source/archilyzer.git` works with nothing but static files (git's dumb-HTTP protocol), a raw browsable tree sits at `/source/tree/`, the tarball is regenerated per deploy, and a gate refuses to publish anything that still carries a denied literal. ## Decisions (made with the operator 2026-09-27; do not re-open) - **Mirror = `main` only**, no other refs (no tags exist). **Scrub** `/home/user → /home/user` in file contents AND commit messages; **keep** the `Co-Authored-By` trailer. (Superseded 2026-10-09: the `Claude-Session` trailer and every Claude session link are removed by a built-in rule and refused by the audit — AGENTS.md, first section.) **`plans/` ships as-is.** The 20 one-off `umtool/song/*.sh` run-log scripts are **deleted**. - The private repo's history is never rewritten. The public mirror is generated by `git-filter-repo` (deterministic: two runs gave identical ids) on a fresh bare clone at every homepage build; its commit ids differ from the private repo's and the page says so. - The mirror directory is `/source/archilyzer.git/` — NEVER a path segment literally named `.git`: wrangler's upload `IGNORE_LIST` (`**/.git`, `**/node_modules`, …) drops it silently, while `archilyzer.git` passes (verified in wrangler 4.107 source). Extensionless files (`HEAD`, `info/refs`) upload fine. - Operator-private inputs live OUTSIDE the repo in `${ARCHILYZER_CONFIG_DIR ?? ~/.config/archilyzer}/`: `source-scrub.txt` (filter-repo `lhs==>rhs` lines; the step always prepends `${os.homedir()}==>/home/user`) and `source-denylist.txt` (one literal per line, `i:` prefix = case-insensitive; every scrub lhs is implicitly denied). Missing file → refusal naming the path. Nothing with the username is in code. - The step is one CLI verb, `archilyzer source publish`, and `buildHomepage` runs it between `composeHomepage` and `next build` (`common/publish/build.ts:973-985`), so `archilyzer build homepage`, the runbooks' home scripts and the `/sites` homepage jobs all produce it. It writes only under gitignored `homepage/public/{source,downloads}` with the link-safe writers in `common/bin/_publicFile.ts` (`ownDir`, `writePublicFile`, `copyPublicFile`). ## Verified facts the implementer must not re-derive - `git clone --no-local --bare --single-branch --branch main` is load-bearing: a same-host `--mirror` copies ~52 MB of loose garbage (`.git/objects/ac/tmp_obj_*`). After filter-repo the pack grows 13.1 → 21.5 MB; `git repack -a -d --max-pack-size=20m` splits it (a 21-pack layout at 2 MB each cloned fine over `python3 -m http.server`). Pages caps: 25 MiB per file, 20,000 files per deployment (`src/pages/constants.ts`); the step refuses at 24 MiB / 15,000. - `--replace-text` rewrites blobs only; one commit MESSAGE carries the path → also `--replace-message` with the same file. filter-repo is not installed; `pipx run --spec git-filter-repo==2.47.0 git-filter-repo` works without sudo (pipx 1.15.0). `git 2.55.0`. gitleaks is installed and clean on the primary (1,541 commits, "no leaks found"). - wrangler's mime map serves `.ts` as `video/mp2t`, `.mjs` as `application/javascript`, `.md` as `text/markdown` → `_headers` must override to `text/plain` under `/source/tree/*` (a NEW pattern here; today `homepage/public/_headers` only sets Cache-Control on `/downloads/*`). Only the ROOT `_headers` is parsed; copies inside the tree are served as text. - `main` today: 1,556 commits, 2,017 tracked files (25.9 MB), 333 directories, 123 paths with `[`/`]` (Next dynamic-route dirs + two variable fonts) → hrefs must be URL-encoded; 0 spaces, 0 non-ASCII, 0 symlinks, 0 tracked `.html`; largest blob 1.57 MB; largest dir 204 entries. Expected public file count ≈ 2,360. - Next static export copies `public/` into `out/` verbatim; `next dev` does NOT serve `public//index.html` at `//` (Pages does) — e2e requests `index.html` explicitly, and NO `public/source/index.html` may exist (it would collide with the `/source/` app route). - **Tailwind v4 scans every file `.gitignore` does not exclude** and pack files are binary (the umtool `.next-*` incident, `.gitignore:151-157`); `homepage/tsconfig.json` includes `**/*.ts`. So `/homepage/public/source` goes into `.gitignore` and `"public"` into the tsconfig `exclude` in R's FIRST commit, before any build with a mirror under `public/`. - `umtool/lib/paths.mjs` and `umtool/lib/projects/report.mjs` are imported by the umtool Next app (11 API routes), where Turbopack rewrites `import.meta.url` into `.next/server/chunks` (`umtool/lib/paths.ts:36-43`, the `SONG_CODE` rule): use a cwd walk to `pnpm-workspace.yaml`, not the `cues.mjs:55-64` `import.meta` pattern (CLI-only). - `umtool/song/um-manifest.json`'s 1,896 `vid` values are used only IN MEMORY by `umtool/song/build-um.mjs:375,624,643` for an untracked review HTML; nothing reads them back from the JSON (`umtool/lib/clips.ts:16` has no `vid`). `thumb-manifest.json`/`thumb-accepted.json` `out` IS read (`umtool/lib/thumbs.ts:165-203` via `resolveInRoots`, `umtool/lib/paths.mjs:120-128`, which binds a relative path to the FIRST root, `SONG_REPORTS`); `bg` is write-only and lives under `SONG_DATA`. The live umtool on :3050 is started with only `UMTOOL_PORT` (`~/reports/release-10/scripts/r10-umtool.sh:45`) and its data is at `/home/user/.claude/jobs/efbe67a7/tmp/song` — a changed `SONG_DATA` default needs an operator symlink before the next umtool restart. - Worktrees do NOT symlink `homepage/public` entries today (only `_headers` is tracked there); `ownDir` covers both cases anyway. filter-repo leaves `/filter-repo/{commit-map,ref-map}` with the PRIVATE shas and `config` holds `remote.origin.url` = the local path → publish from an ALLOWLIST of files, never the bare dir whole. ## Slice Q — fix-forward the hardcoded paths (branch `r12/paths-fix`, lands first) Owns `umtool/**`, `WORKTREES.md`, `common/controller/storageLocations{,.test}.ts`, `common/lib/storageVolumes{,.test}.ts`, `common/views/storage.test.ts`, `common/lib/envVars.ts` (only the defaults of the umtool variables it changes) + the regenerated `ENVIRONMENT.md`; shared: `editor/CHANGELOG.md`, `plans/release-12.md` (created by Q with the release-11 shape), `plans/source-mirror.md` (this plan, Q's first commit). 1. `umtool/lib/paths.mjs:86`: add `export const REPO_ROOT = findRepoRoot(process.cwd())` (walk up until `pnpm-workspace.yaml` exists; fall back to `path.resolve(cwd, "..")`; comment why cwd, not `import.meta.url`), and `CHANNELS_DIR = path.resolve(process.env.CHANNELS_DIR ?? (process.env.TRANSCRIPTS_DIR ? join(TRANSCRIPTS_DIR, "channels") : join(REPO_ROOT, "transcripts", "channels")))`. `umtool/lib/projects/report.mjs:39-41`: `GLOBAL_CHANNELS_DIR = () => CHANNELS_DIR` imported from `../paths.mjs` (one definition). Leave `cues.mjs` alone. 2. `umtool/song/spec.mjs:31`, `umtool/song/video-dir.mjs:53`: `process.env.VIDEO_ROOT ?? path.join(os.homedir(), "reports", "quartering-uh-song", "videos")` (add `import os`). 3. `umtool/song/paths.mjs:19`: `SONG_DATA` = `fs.realpathSync` of `process.env.SONG_DIR ?? path.join(os.homedir(), ".local", "share", "archilyzer", "song")` (realpath so `SONG_SCRATCH = dirname(SONG_DATA)` keeps pointing at the real job dir through a symlink); update the header comment (the symlink is the supported way to keep the data where it is). 4. `git rm` the 20 run-log scripts (`backfill-build, mk-fatal-finish{,2,3}, mk-rebuild, ms2-{bg3, drums,full,jerbg,play-rebuild,triangle}, pk2, pk3, pk-v12, pkmn-{rebuild,video}, rpg-remake-v5, rpg-short, vshort, yoshi-rebuild}.sh`). Reword `umtool/lib/jobs.ts:8-17,71-72` and the static prose in `umtool/components/BuildChain.tsx:144-150` to past tense ("were one-off shell run logs that hardcoded their paths; not in the tree, not runnable from here"). Historical mentions in `debox-bg.mjs:24`, `hush-head.mjs:47`, `pick-take.mjs:26`, `video-dir.mjs:5,137` stay (record as left). 5. `um-manifest.json`: one-off strip of `vid` (record the command; indent 1 as the writer uses; `git diff --stat` shows only removed `"vid"` lines); `build-um.mjs:250` writes `items.map(({ vid: _v, ...rest }) => rest)` with a comment (the page needs the `file://` URL, the tracked manifest must not carry a machine path); `:224` unchanged. 6. `thumb-manifest.json` + `thumb-accepted.json`: one-off rewrite `out` relative to `~/reports/quartering-uh-song` and `bg` relative to the song data dir; writer `umtool/song/make-thumb.mjs:258` → `out: relTo(SONG_REPORTS, OUT), bg: relTo(SONG_DATA, BG)` with a comment naming the two roots. Verify and record: from `umtool/`, `resolveInRoots ("thumbs/.jpg")` prints the same absolute file as before, and `thumbs.ts:165,199` yield the same labels. 7. Text: `WORKTREES.md:119` → `cwd /path/to/checkout/editor`; `umtool/song/fit-hooks.mjs:93` → ``; the `/run/media/user/` literals in the five `common/` files → `/run/media//` in comments and `/run/media/operator/` in test fixtures (change both sides of each assertion); the one `editor/CHANGELOG.md` line → `/run/media//` (a released entry is normally left as written; this is a path example, not a name a reader searches for — say so in the record). 8. If `common/lib/envVars.ts` states a default for `SONG_DIR`, `VIDEO_ROOT` or `CHANNELS_DIR`, update it to the new default and regenerate `ENVIRONMENT.md` (`pnpm archilyzer docs env`). 9. Gate: `git grep -c -i 'user' HEAD -- . ':!plans/'` → empty; tsc; `node --check` on every changed `.mjs`; common 2,112 (edited, not added); `test:scripts` 185 + 1; editor unit 85; mcp 269; `pnpm archilyzer docs env --check` clean; `pnpm --filter umtool exec next build`; `cd umtool && node -e 'import("./lib/paths.mjs").then(p=>console.log(p.CHANNELS_DIR,p.SONG_DATA))'` prints the repo's `transcripts/channels` and the XDG path; umtool e2e `faces.spec.ts deck.spec.ts clip-bench.spec.ts` via `node scripts/worktree.mjs run -- pnpm --filter umtool run e2e …` (the fixture sets `CHANNELS_DIR`/`SONG_DIR`, proving env still wins). One `[Unreleased]` bullet in `editor/CHANGELOG.md`. **As shipped (2026-09-28, `r12/paths-fix`; record: `release-12.md`, "Slice Q, as shipped"):** - **Steps 1–7 as written, with these additions:** - `SONG_REPORTS` moved into `song/paths.mjs` beside a new `relTo(root, p)`, and `lib/paths.mjs` re-exports it. The song scripts import only siblings, because the e2e fixture runs copies of them. - `accept-thumb.mjs`, the other writer of `thumb-accepted.json`, records `out` relative too. - `make-thumb` passes `path.resolve(OUT)` / `path.resolve(BG)` to `relTo`. - deck.spec pins the relative `out`. - **Step 3:** a missing `SONG_DIR` path is used as given, with no realpath. - **Step 8 was a no-op:** `envVars.ts` declares no umtool variable. The defaults are in `umtool/docs/cli.md` instead. - **Step 9's common baseline is 2,114** (O6c's +2), unchanged by Q. ## Slice R — `archilyzer source publish` + `/source` (branch `r12/source-mirror`, after Q) Owns `common/publish/{source,sourceAudit,sourceTree}.ts` + tests, `common/lib/sourceManifest.ts`, `common/publish/build.ts` (`buildHomepage` only), `common/bin/archilyzer.ts` + `_cli.test.ts`, `common/bin/doctor.ts` + test, `common/lib/paths.ts` + `common/lib/envVars.ts` (the new variables) + the regenerated `ENVIRONMENT.md`, `homepage/**`, `.gitignore`, `create-archives.sh` (deleted), `README.md`, `SETUP.md`, `PUBLISH.md`, `homepage/content/docs/install.md`; shared: both changelogs, `plans/release-12.md`. **New variables, declared in `common/lib/envVars.ts` and read through `getPaths()`**: `ARCHILYZER_CONFIG_DIR` (default `~/.config/archilyzer`), `SOURCE_SCRUB_FILE`, `SOURCE_DENYLIST_FILE` (defaults inside the config dir), `ARCHILYZER_SOURCE_SCRATCH` (default `os.tmpdir()`). None is test-only, so none carries `E2E_`. **`archilyzer doctor`** gains a "source publish" block: which filter-repo command resolves (or the install line), whether gitleaks is on PATH, whether the two operator files exist, and the last published `mirrorHead` if there is a manifest. Read-only, like the rest of doctor. **The `/sites` homepage jobs run the step too** (they call `buildHomepage` in-process). A refusal fails the job with the redacted audit report in its log. `--no-source` stays CLI-only. ### R1. `common/publish/source.ts` — `publishSource(opts): Promise` (0 ok, 1 refused) `SourcePublishOpts = PublishOpts & { force?, check?, keepScratch?, branch = "main", sourceRepo? (default: git -C monorepoRoot rev-parse --path-format=absolute --git-common-dir — a worktree build mirrors the PRIMARY's main), publicDir? (default homepage/public, HOMEPAGE_PUBLIC_DIR honoured), scrubFile?, denylistFile?, filterRepo?: string[]|null, gitleaks?: string|null (null = skip, tests), scratchRoot? (ARCHILYZER_SOURCE_SCRATCH ?? os.tmpdir()), now? }`. `class SourceRefusal extends Error`. Every child through the existing `runChildIntoLog` with `AbortSignal.any([signal, AbortSignal.timeout(ms)])`; scratch = `mkdtemp(/archilyzer-source-)`, removed in `finally` unless `keepScratch`. Order: 1. `sourceCommit = git rev-parse refs/heads/main` in the common dir. 2. `loadScrubRules` / `loadDenylist` (R1 files); `rulesHash = sha256(rules + denylist)`. 3. **Skip** unless `force`/`check`: existing `public/source/manifest.json` with equal `sourceCommit` + `rulesHash` AND `public/source/archilyzer.git/info/refs` AND the tarball present → log `[source] up to date at ; skipping (--force to rebuild)`, return 0. 4. `resolveFilterRepo()`: `git filter-repo --version` → `["git","filter-repo"]`; else pipx → `["pipx","run","--spec","git-filter-repo==2.47.0","git-filter-repo"]`; else `SourceRefusal` with the install line (`pipx install git-filter-repo`). Log the choice and version. 5. `git clone --no-local --bare --single-branch --branch main --quiet /bare` (120 s); `git -C bare remote remove origin`. 6. Write `replace.txt`; run ` --force --replace-text replace.txt --replace-message replace.txt` in `bare` (900 s; `--force` because origin was removed); `rm -rf bare/filter-repo`. 7. `git repack -a -d -q --max-pack-size=20m` (300 s), `git prune-packed`, `git pack-refs --all`, `git update-server-info`; refuse if `git count-objects -v` `count` ≠ 0 or `objects/info/packs` is empty. 8. `mirrorHead = git rev-parse refs/heads/main`, `subject = git log -1 --format=%s` (in bare). 9. **Gate**: `auditBare` (R2). Any hit → print the redacted report, return 1, write nothing. 10. Tree: `git archive --format=tar -o scratch/tree.tar refs/heads/main` → `tar -xf … -C scratch/stage/source/tree`; `writeTreeIndexes` (R3). 11. Tarball: `git archive --format=tar.gz -9 --prefix=archilyzer/ -o scratch/stage/downloads/archilyzer-source.tar.gz refs/heads/main`; sha256 by stream; `snapshot.json` in today's shape `{generatedAt, commit: mirrorHead, subject, bytes, sha256}` (the `Snapshot` type in `homepage/app/lib/snapshot.ts` stays). 12. Stage the mirror from an ALLOWLIST only: `HEAD`, `packed-refs`, `info/refs`, `objects/info/packs`, `objects/pack/*.pack`, `objects/pack/*.idx` → `stage/source/archilyzer.git/` (no `config`, `hooks/`, `description`, `logs/`, `*.rev`, `*.bitmap`). 13. `auditFiles(stage)` — a byte sweep of every staged file except `*.pack`/`*.idx` (the object walk covered those). Hit → return 1. 14. Limits: total files > 15,000 or any file > 24 MiB → refusal. 15. `--check` stops here (prints the report + counts, returns 0, writes nothing). 16. Install, link-safe: `ownDir(public/source)`; `rm public/source/manifest.json` FIRST (a crash mid-copy leaves the page in its empty state, never a stale manifest over a half tree); `rm -rf public/source/{archilyzer.git,tree}`; `cp -r stage/source/*`; `ownDir(public/downloads)`; `copyPublicFile(tarball)`; `writePublicFile(snapshot.json)`; LAST `writePublicFile(manifest.json)`. 17. One summary line: `[source] published main as : N files, M MB (mirror P packs, tree D dirs), tarball K MB sha256 `. `common/lib/sourceManifest.ts` (leaf): `SOURCE_MANIFEST_VERSION = 1`, `MIRROR_DIR = "archilyzer.git"`, `CLONE_URL = ${PROJECT_URL}/source/archilyzer.git`, `TREE_HREF = "/source/tree/"`, and `SourceManifest = { version: 1; generatedAt; branch: "main"; sourceCommit; mirrorHead; subject; files; bytes; mirror: {files, bytes, packs}; tree: {files, dirs, bytes}; tarball: {href, bytes, sha256}; cloneUrl; treeHref; audit: {literals, objects, commits, gitleaks: "clean"|"skipped"}; rulesHash; tools: {git, filterRepo} }`. `buildHomepage` becomes compose → `if (!opts.skipSource) { const c = await (opts.publishSource ?? publishSource)({paths,onLog,signal}); if (c !== 0) return c; }` → `next build`. `r10-home.sh` is unchanged and fails closed (a refusal = `HOME_BUILD_FAILED`, no deploy). CLI table rows: `build homepage [--no-source]`; `source publish [--force] [--check] [--keep-scratch]`; `source audit []` (default: the published `homepage/public/source/archilyzer.git`; the rollout runs it on the LIVE clone). `SOURCE_DENYLIST_FILE` / `SOURCE_SCRUB_FILE` env override the file paths (the gate uses it to plant a literal). ### R2. `common/publish/sourceAudit.ts` `parseDenylist(text)` → `{bytes, ci}[]`; `scanBuffer(buf, literals)`; `redactHit` (±24 bytes, the literal → `[REDACTED]`, non-printables `.`); `auditObjects(bare, literals)` = ONE stream `git -C bare cat-file --batch-all-objects --unordered --batch` with a framing parser scanning EVERY object — blobs, commits (message + identity lines), trees (entry NAMES), tags; `auditFiles(dir, literals, skip=/\.(pack|idx)$/)`; `runGitleaks(bare)` = `gitleaks git --no-banner --redact --exit-code 3 --report-format json --report-path /gitleaks.json ` (300 s; 0 → "clean", 3 → parse `RuleID`/`File`/`Commit` into hits, other → `SourceRefusal`; ENOENT → "skipped" + a WARNING line); `auditBare` combines; `formatAuditReport` never prints a literal (shows `#n (x…, len L)`). Refusal output ends with `[source] add a rule to ~/.config/archilyzer/source-scrub.txt or drop the file from history, then re-run.` ### R3. `common/publish/sourceTree.ts` and `_headers` `hrefFor(entry)` = `encodeURIComponent(name)` + `/` for dirs (`[slug]` → `%5Bslug%5D/`); `escapeHtml`; `renderTreeIndex({relDir, entries, mirrorHead, generatedAt})` — one dependency-free template (~40 lines inline CSS honouring `prefers-color-scheme`, ``, breadcrumb with relative `../` hops, `..` row except at root, dirs before files sorted by name, sizes via `formatBytes` with raw bytes in `title`, footer `main @ · generated · clone`); `writeTreeIndexes(treeDir, meta)` walks with `readdir({withFileTypes:true})`, REFUSES if a directory already has an `index.html`, returns `{files, dirs, bytes}`. Append to the tracked `homepage/public/_headers` (exact text; later rules win for the same header): ``` # The published source (`archilyzer source publish`, common/publish/source.ts). /source/manifest.json Cache-Control: public, max-age=300, must-revalidate /source/archilyzer.git/* Cache-Control: public, max-age=300, must-revalidate # The raw tree is PLAIN TEXT whatever the extension: wrangler's mime map would # send .ts as video/mp2t, .mjs as application/javascript, .md as text/markdown # and .html as a page on this origin. /source/tree/* Content-Type: text/plain; charset=utf-8 X-Content-Type-Options: nosniff X-Robots-Tag: noindex Cache-Control: public, max-age=300, must-revalidate # ...except the generated directory indexes and the binary files. /source/tree/ Content-Type: text/html; charset=utf-8 /source/tree/*/ Content-Type: text/html; charset=utf-8 /source/tree/*.ttf Content-Type: font/ttf /source/tree/*.m4a Content-Type: audio/mp4 /source/tree/*.zip Content-Type: application/zip /source/tree/*.onnx Content-Type: application/octet-stream /source/tree/*.svg Content-Type: image/svg+xml Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline' ``` (`.md` deliberately text/plain so it renders raw. The binary list is what the tree holds today: 3 `.ttf`, 3 `.m4a`, 1 `.zip`, 1 `.onnx`, 6 `.svg`.) ### R4. Homepage - `homepage/app/lib/source.ts`: `loadSourceManifest()` — `public/source/manifest.json` with `version === 1`, 40-hex `mirrorHead`, 64-hex sha, AND `public/source/archilyzer.git/info/refs` and the tarball present (the `snapshot.ts:25-46` rule), else null. - `homepage/app/lib/nav.ts`: `{ href: "/source/", label: "Source" }` after Docs. - `homepage/app/source/page.tsx` (`PageShell`/`PageHeading`/`Fact` rows as `downloads/page.tsx`): title "Source"; standfirst "The canonical public copy: a read-only git mirror, its raw tree, and a tarball."; one paragraph in the operator's voice: "There is no GitHub, by choice. This site is where the code lives in public: a read-only mirror of the private main branch, regenerated with every deploy of this site. Commit ids differ from the private repository because paths were scrubbed on the way out — same history, different hashes. Nothing here takes a push or a pull request."; the clone command in `
`; Facts (`source-mirror-head`, reflects private
  main, subject, generated, files/size); links "Browse the tree" (`source-tree-link`) and the tarball
  with bytes + sha256 (`source-tarball-link`); sections "What is mirrored" and "License" (MIT). Empty
  state `data-testid="source-empty"`: "No source published in this build." + how it is produced.
- `homepage/app/downloads/page.tsx`: keep every phrase `downloads.spec.ts` asserts; add one sentence
  pointing at `/source/` for history; the empty-state sentence names `archilyzer source publish`
  instead of `./create-archives.sh`.
- `homepage/tsconfig.json` `exclude: ["node_modules", "public"]`; `.gitignore` adds
  `/homepage/public/source` with the Tailwind note — BOTH in R's first commit.
- `homepage/e2e/marketing.spec.ts:42-47` nav list gains `["Source", "/source/"]`.

### R5. Docs

Delete `create-archives.sh` and every reference: `README.md:485`, `SETUP.md:162,182`,
`homepage/app/downloads/page.tsx:102`, `homepage/app/lib/snapshot.ts:4,23`, `.gitignore:49,93`
(grep again; lines move). `README.md`: the clone command as the canonical public copy (`main` only;
ids differ because paths are scrubbed), the tree at `/source/tree/`, the tarball at `/downloads/`,
regenerated by `archilyzer source publish` inside `archilyzer build homepage`. `SETUP.md` and
`homepage/content/docs/install.md` "Get the code": clone first, tarball as the no-git alternative.
**`PUBLISH.md`**: a new section `## The source mirror (homepage)` after `## Cloudflare Pages`, with
the 20,000-file / 25 MiB caps (the step refuses at 15,000 / 24 MiB), the `.git` segment trap, the
dumb-HTTP file set, the `_headers` block, the two operator files, and that only `wrangler pages dev`
honours `_headers` locally. `ENVIRONMENT.md` regenerated. `[Unreleased]` bullets in
`editor/CHANGELOG.md` and `homepage/CHANGELOG.md` (the homepage's keeps its own convention).

### R6. Tests

- `common/publish/source.test.ts` (~9): scrub-rule loading (built-in homedir rule first, comments,
  missing file → refusal naming the path); denylist (`i:`, implied lhs); skip logic; limits; the
  allowlist (a planted `config`/`hooks/`/`filter-repo/` are never copied); manifest shape; a **round
  trip** in a temp repo (`git init -b main`, identity set, the `GIT_*` env vars deleted as
  `common/controller/cutRelease.test.ts` does): three commits with a planted literal in one blob and
  one message, rule `/srv/plantedhome==>/home/user`, `gitleaks: null`, `publicDir` temp → `git clone
  file:///source/archilyzer.git` succeeds, HEAD = `manifest.mirrorHead` ≠ `sourceCommit`,
  the message reads `/home/user`, `git grep -F plantedhome $(git rev-list --all)` empty, no `config`
  in the published dir, `info/refs` names `refs/heads/main`, `objects/info/packs` ≥ 1, snapshot sha
  = fresh sha256 of the tarball, `tree/index.html` and one subdir index exist, no
  `source/index.html`; a **planted-literal refusal** (denylist without a rule → 1, no manifest,
  downloads untouched, report free of the literal); `--check` writes nothing. The two filter-repo
  tests `t.skip` when `resolveFilterRepo()` throws — the gate requires they RAN.
- `common/publish/sourceAudit.test.ts` (~6): blob / commit / tree-name hits, clean repo, redaction,
  gitleaks skipped. `common/publish/sourceTree.test.ts` (~5): encoding, escaping, ordering,
  breadcrumbs, the existing-index refusal. `_cli.test.ts` (+3 usage rows). `build.test.ts` (+1:
  `skipSource` never calls the injected fake; a fake returning 1 fails the build).
- `homepage/e2e/source.spec.ts` (5; the suite runs against `next dev` + `homepage/public` on disk,
  two-state like `downloads.spec.ts`; the R gate runs `source publish` in the worktree BEFORE the
  suite so the manifest branch is exercised — record which): the page's fixed copy; with a manifest
  the clone command, tarball href (200 via `page.request`), and the sha on the page = `snapshot.json`
  = `manifest.json`, else the empty state; the mirror files (`/source/archilyzer.git/HEAD` = `ref:
  refs/heads/main`, `info/refs` contains `\trefs/heads/main`, `objects/info/packs` has a
  `P pack-` line); tree indexes (`/source/tree/index.html` 200, one dir href + `index.html` 200, one
  file href 200, the fonts dir index contains `%5B` and that href resolves). Content-Type cannot be
  asserted under `next dev` (`_headers` is Pages-only) — the preview deploy checks it.

### R7. Gates (worktree root, after merging `main` with Q)

tsc (must stay fast — proves the tsconfig exclude); common 2,112 + ~26 (doctor included);
`test:scripts` 185 + 1; editor unit 85; mcp 269; homepage unit 2; `pnpm archilyzer docs env --check`
clean; `pnpm --filter yt-dlp-transcript-common exec tsx bin/archilyzer.ts build
homepage` in the worktree (the parent creates `~/.config/archilyzer/{source-scrub,source-denylist}.txt`
BEFORE the slice starts; the implementer never reads them aloud and never commits them) → `homepage/out/source/archilyzer.git/
info/refs`, `manifest.json`, the tarball; record `find homepage/out -type f | wc -l` and the build's
extra wall time; `git clone file://$WT/homepage/out/source/archilyzer.git` AND, with `python3 -m
http.server 8765` in `homepage/out`, `git clone http://127.0.0.1:8765/source/archilyzer.git` — both
HEADs = `manifest.mirrorHead`; `git -C  grep -c -i user $(git rev-list --all)` empty; `source
audit /.git` clean; the gate EXERCISED: `SOURCE_DENYLIST_FILE` pointing at a copy with a literal
that IS in history (e.g. `Co-Authored-By`) → `source publish --check` exits 1 with redacted hits and
`public/source/manifest.json`'s mtime unchanged; `build homepage --no-source` → the `/source/` page's
empty state; `PATH` without pipx → the install-line refusal; `archilyzer doctor` prints the new
block; homepage e2e 31 → 36 with the manifest present, `downloads.spec.ts` unchanged. Record the
resolved filter-repo command and version.

**As shipped (2026-09-28, `r12/source-mirror`; record: `release-12.md`, "Slice R, as shipped" and
its "Review"):**
- **R1–R6 as written, with these corrections found by running them:**
  - **`_headers`:** Pages APPENDS a header a later matching rule sets again. Each override now
    detaches with `! Content-Type` first, and `homepage/app/lib/headers.test.ts` pins it.
  - **The homepage tsconfig excludes `out` as well as `public`,** and its eslint config ignores
    `public/source/**`.
  - **The mirror carries a loose `refs/heads/main`** beside `packed-refs`, because git needs a
    `refs/` directory before a `file://` clone or `source audit` will read it.
  - **filter-repo reads `#` lines as literals,** so `replace.txt` is written without them.
  - **The clone takes `--no-tags`,** and filter-repo `--replace-refs delete-no-add --quiet`.
  - **`--no-source` REMOVES the previous publish.**
  - **The header nav moves to `md`.**
- **No `rulesHash` and no literal count in the published manifest.**
  - The skip key is `homepage/.source-publish.json` (gitignored and dockerignored):
    `{sourceCommit, mirrorHead, rulesHash, filterRepo}`.
  - `rulesHash` covers the rules, the literals and `SOURCE_STEP_VERSION`. Bump it whenever the
    scrub or the audit changes.
- **After the review (M1): a refusal withdraws the source everywhere it could ship from.**
  - The step removes the last publish from `homepage/public` (not under `--check`).
  - `buildHomepage` removes it from `homepage/out`.
  - `deployHomepage` refuses an `out/` whose source was not audited under today's rules, of
    today's `main`, or that has no `/source` page at all.
- **The audit report names a literal by where it was written** (`denylist line 3 (len 5)`) and a
  hit by object, field and byte offset. It prints no byte of any object.
- **The review's other fixes:**
  - a tracked `404.html` is refused;
  - a BOM, CRLF and a trailing `/` on the home dir are handled, and an empty left side refuses;
  - a checkout with no git repository builds with the `/source` empty state (and the CLI refuses
    with the same sentence);
  - a scratch root inside the checkout or the public dir is refused, and `--keep-scratch` deletes
    `replace.txt`;
  - `E2E_EXPECT_SOURCE=1` makes the empty state fail the spec;
  - a malformed manifest is the empty state, never a crash.
- **Left:** compressed content is opaque to the byte search (review L6; a known limit in
  PUBLISH.md).
- **The baselines moved:** common 2,114 → **2,146**; homepage unit 2 → **7**; homepage e2e 31 →
  **36**.

## Rollout (parent; nothing here edits the primary's tracked files)

Release 11 is merged and NOT rolled out. A production homepage deploy of release 12 also ships
release 11's homepage changes, so production waits for the operator's release 11 rollout (or is
done as part of it, on the operator's word). The PREVIEW deploy below is safe at any time.

0. Before slice R's gate, the parent creates `~/.config/archilyzer/` (mode 700) with
   `source-scrub.txt` (`/run/media/user==>/run/media/user`, `` `user`==>`user` ``) and
   `source-denylist.txt` (mode 600: `user`, the hostname from `hostname`, the Gmail address the
   session already knows). **The operator adds what only they know** — real name, other handles,
   anything else that must never appear — before the first production publish. `pipx install
   git-filter-repo` (so a build needs no network and the editor's jobs find it on PATH). Q's
   operator step, BEFORE any umtool restart: `mkdir -p ~/.local/share/archilyzer && ln -s
   /home/user/.claude/jobs/efbe67a7/tmp/song ~/.local/share/archilyzer/song`.
1. `archilyzer source publish --check`, then `archilyzer build homepage` (r10-home.sh's first line);
   check `homepage/out/source/archilyzer.git/info/refs` and the file count.
2. **Preview first**: `archilyzer deploy homepage --preview source` → `https://source.archilyzer.pages.dev`.
   Live: `git clone https://source.archilyzer.pages.dev/source/archilyzer.git` (the edge-side unknown:
   extensionless `HEAD`/`info/refs`, `.git` inside a segment past Cloudflare's managed rules, the dumb
   fallback after the `?service=` probe); clone HEAD = `manifest.mirrorHead`; `curl -sI
   …/source/tree/common/lib/paths.ts` → `text/plain; charset=utf-8` + `nosniff`; `…/source/tree/common/`
   → text/html; `…/source/tree/umtool/report-to-video/fonts/Archivo%5Bwdth,wght%5D.ttf` → 200 font/ttf;
   `…/source/tree/homepage/app/docs/%5Bslug%5D/page.tsx` → 200; the tarball's sha256 = `snapshot.json`
   = the page; `archilyzer source audit /.git` clean.
3. Production, after release 11's rollout: `archilyzer deploy homepage` (or `pnpm ops
   build-homepage --json '{"deploy":true}'`, which also proves the step inside the editor's job),
   the same checks on `archilyzer.pages.dev`; the record's `## Rollout`, STATE ("Now"), FACTS (the
   `.git` segment trap, Tailwind/tsconfig scanning of `public/`, dumb HTTP on Pages), memory, and an
   HTML runbook at `~/reports/release-12/RUNBOOK.html` (handoffs are HTML).
4. If the edge refuses the dumb clone: the tree and tarball still stand; the mirror needs another host
   (R2 behind a custom domain, out of scope) — record, do not improvise.

## Verification summary

Unit: the round trip (clone → scrub → repack → `update-server-info` → clone back), the planted-literal
refusal, the audit's three hit kinds, the index encoding. Build: `archilyzer build homepage` in the
worktree, then a dumb-HTTP clone from `homepage/out` over `python3 -m http.server`. Live: the preview
deploy's clone, content types, bracket paths, tarball sha, and `source audit` on the live clone. Docs:
`grep -rn 'create-archives' README.md SETUP.md homepage/` shows nothing.

## Risks (flagged, not solved here)

- Edge-side serving of `HEAD`/`info/refs`, the `.git`-inside-a-segment path and bracket paths are
  proved upload-side only; the preview deploy is the test. Cloudflare's managed "git exposure" rules
  match `/.git/`, ours is `/archilyzer.git/`.
- `pipx run` needs network on first use and every 14 days; the editor's `/sites` homepage jobs run in
  the editor's environment where `pipx`/`gitleaks` may be off PATH → the build refuses (fail closed)
  or skips gitleaks with a warning. `pipx install git-filter-repo` once removes both.
- filter-repo determinism holds for a fixed version; an upgrade that changes rewriting defaults
  changes `mirrorHead` (readers see a non-fast-forward; the page says ids may change); the manifest
  records the version. Object ids are stable across git versions; pack bytes are not (irrelevant).
- The denylist `user` is exact-case; a future transcript quote "user that" trips the gate —
  intended (add a rule or reword).
- About a minute more per homepage build (filter-repo ~20-60 s); an unchanged `main` skips it.
- The first publish replaces the 2026-08-12 tarball and `Snapshot.commit` becomes a mirror sha; only
  the `/source` page's "reflects private main" row links the two — deliberate.