commit 8487b6f5a8c51929f064aafd4fd030cbd7b5946c
parent 7490472bdd8784aad64435f6937baae47bbaf442
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Mon, 28 Sep 2026 11:49:19 -0400
plans: release 12 — the source mirror plan and the record file
The plan (written 2026-09-27, re-checked 2026-09-28 against 7490472b)
enters the repo on slice Q's branch as plans/source-mirror.md, verbatim;
plans/release-12.md takes release 11's shape (header, the slices, Record,
Rollout).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
2 files changed, 500 insertions(+), 0 deletions(-)
diff --git a/plans/release-12.md b/plans/release-12.md
@@ -0,0 +1,36 @@
+# Release 12 — the source mirror: a read-only git clone on the project site
+
+`main` at `90c7f776` (release 11 merged in full and NOT rolled out: the cut, the :3001 restart, a
+settings save and the deploys are still owed by the operator, `release-11.md` "Rollout"). Release
+12 is the operator's ask of 2026-09-27: the repo mirrored on the project site
+(https://archilyzer.pages.dev) as static, read-only files — `git clone
+https://archilyzer.pages.dev/source/archilyzer.git` over git's dumb-HTTP protocol, a raw tree at
+`/source/tree/`, the tarball regenerated per deploy, and a gate that refuses to publish a denied
+literal. Plan: [`source-mirror.md`](source-mirror.md) (written 2026-09-27 in plan mode, re-checked
+2026-09-28 against `90c7f776`; it entered the repo as slice Q's first commit, never as a direct
+commit to `main`). Rules: `plans/tools/implementer-rules.md`, with the commit trailer this release's
+prompts give.
+
+**The operator's standing choices** (`source-mirror.md`, "Decisions"; not re-opened):
+- **Mirror = `main` only.** `/home/user → /home/user` is scrubbed in file contents AND commit
+ messages; the `Co-Authored-By` and `
+- **The private repo's history is never rewritten.** The mirror is generated by `git-filter-repo`
+ on a fresh bare clone at every homepage build; its commit ids differ from the private repo's.
+- **Operator-private inputs live outside the repo**, in `${ARCHILYZER_CONFIG_DIR ??
+ ~/.config/archilyzer}/`. Nothing carrying the username is in code.
+- **Working alongside the parallel session:** 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 |
+|---|---|---|---|
+| Q | `r12/paths-fix` | Fix-forward the hardcoded paths: umtool's `CHANNELS_DIR` from the repo root, `VIDEO_ROOT` and `SONG_DATA` from the home dir (XDG for the song data), the 20 one-off `umtool/song/*.sh` run logs deleted, the machine paths stripped from three tracked umtool manifests, `/run/media/user` out of `common/` | `umtool/**`, `WORKTREES.md`, `common/controller/storageLocations{,.test}.ts`, `common/lib/storageVolumes{,.test}.ts`, `common/views/storage.test.ts`, `common/lib/envVars.ts` (umtool defaults only) + `ENVIRONMENT.md` |
+| R | `r12/source-mirror` | `archilyzer source publish` (filter-repo scrub, repack, dumb-HTTP files, the audit gate, the raw tree, the tarball) run by `buildHomepage`; the `/source/` page; doctor's source block; the docs; `create-archives.sh` deleted | `common/publish/{source,sourceAudit,sourceTree}.ts` + tests, `common/lib/sourceManifest.ts`, `buildHomepage`, `common/bin/{archilyzer,doctor}.ts` + tests, `common/lib/{paths,envVars}.ts` (new variables), `homepage/**`, `.gitignore`, the docs |
+
+**Order:** Q lands first; R branches from `main` after Q merges. The shared files are
+`editor/CHANGELOG.md` (`[Unreleased]`) and this record.
+
+## Record
+
+## Rollout
diff --git a/plans/source-mirror.md b/plans/source-mirror.md
@@ -0,0 +1,464 @@
+# 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/<slug>`. 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** `Co-Authored-By` and `
+ **`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/<dir>/index.html` at `/<dir>/` (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 `<bare>/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/<x>.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` →
+ `<user>`; the `/run/media/user/` literals in the five `common/` files → `/run/media/<user>/` in
+ comments and `/run/media/operator/` in test fixtures (change both sides of each assertion); the
+ one `editor/CHANGELOG.md` line → `/run/media/<user>/` (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`.
+
+## 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<number>` (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(<scratchRoot>/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 <sha>; 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 <common-dir> <scratch>/bare`
+ (120 s); `git -C bare remote remove origin`.
+6. Write `replace.txt`; run `<filterRepo> --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 <src12> as <mirror12>: N files, M MB (mirror P
+ packs, tree D dirs), tarball K MB sha256 <sha12>`.
+
+`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
+[<git dir>]` (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 <scratch>/gitleaks.json <bare>` (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`, `<meta name=robots content=noindex>`,
+breadcrumb with relative `../` hops, `..` row except at root, dirs before files sorted by name,
+sizes via `formatBytes` with raw bytes in `title`, footer `main @ <mirrorHead12> · generated <at> ·
+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 `<pre data-testid="source-clone">`; 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://<public>/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 `<mirrorHead>\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 <clone> grep -c -i user $(git rev-list --all)` empty; `source
+audit <clone>/.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.
+
+## 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 <clone>/.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.