# 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` trailer stays; `plans/` ships as-is. (Superseded 2026-10-09: the `Claude-Session` trailer and every Claude session link are removed from the mirror's whole history by a built-in rule, and the audit refuses any left — AGENTS.md, first section.) - **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 ### Slice Q, as shipped — fix-forward the hardcoded paths (2026-09-28) Branch `r12/paths-fix` off `main` `90c7f776`, worktree `/home/user/Projects/r12-paths-fix` (ports 3901/3911, umtool e2e 3951/3952), one Opus implementer. Plan: [`source-mirror.md`](source-mirror.md), "Slice Q", steps 1–9. Why: slice R publishes the repo, and its gate refuses any denied literal. The Unix username was in code as machine paths (umtool's defaults, 20 run-log scripts, three tracked manifests) and in path examples (`/run/media/user`). Scrubbing the published copy is R's job. Q fixes the source, so the code no longer depends on one machine's home directory. Scratch files `q-*` in the job's `tmp`. **What shipped.** - **Step 1: `CHANNELS_DIR` comes from the checkout.** `umtool/lib/paths.mjs` gains `findRepoRoot(start)` and `REPO_ROOT = findRepoRoot(process.cwd())`. The walk goes up to `pnpm-workspace.yaml` and falls back to the cwd's parent. It starts from the cwd, not `import.meta.url`, which is the `SONG_CODE` rule; the comment says why. `CHANNELS_DIR` is `CHANNELS_DIR ?? $TRANSCRIPTS_DIR/channels ?? /transcripts/channels`, resolved. `lib/projects/report.mjs`' `GLOBAL_CHANNELS_DIR` is `() => CHANNELS_DIR`, imported from `../paths.mjs`, so there is one definition. `cues.mjs` is untouched. - **Step 2: `VIDEO_ROOT`.** `song/spec.mjs` and `song/video-dir.mjs` use `process.env.VIDEO_ROOT ?? path.join(os.homedir(), "reports", "quartering-uh-song", "videos")`, with `import os` added. - **Step 3: `SONG_DATA`.** `song/paths.mjs` resolves `SONG_DIR ?? ~/.local/share/archilyzer/song` through `realpathSync`. A path that does not exist has no realpath, so it is **used as given**: a missing default still gives a `SONG_DATA`, which every reader finds empty. The header comment says the symlink is the supported way to keep the data where it is. It also says why the realpath: `SONG_SCRATCH = dirname(SONG_DATA)` keeps pointing at the real job dir. - **Step 4: the 20 run logs are deleted** with `git rm`: - `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`. That was every `.sh` in `umtool/song/`. Nothing enumerated or ran them. `lib/jobs.ts`' header and timeout doc, and `BuildChain.tsx`'s comment and on-screen note, now say it in the past tense: "one-off shell run logs that hardcoded their paths … not in the tree, and not runnable from here". No spec asserts that note's text. - **Step 5: `um-manifest.json` loses `vid`.** The one-off below stripped all 1,896 `vid` values from 3,609 items. `build-um.mjs` now writes `[...merged.values()].map(({ vid: _v, ...rest }) => rest)`, with a comment. The page keeps `vid` in memory: `META` is `JSON.stringify(items)`. The item push at `:224` is unchanged. - **Step 6: relative paths in the thumb manifests.** The one-off below made `out` relative to `~/reports/quartering-uh-song` and `bg` relative to the song data dir: 18 paths in `thumb-manifest.json` and 8 in `thumb-accepted.json`. `make-thumb.mjs` writes `out: relTo(SONG_REPORTS, path.resolve(OUT))` and `bg: relTo(SONG_DATA, path.resolve(BG))`, with a comment naming the two roots. `path.resolve` is there because both are CLI arguments and could be cwd-relative. - **Step 7: path examples name no user.** - `WORKTREES.md:119` → `cwd /path/to/checkout/editor`. - `fit-hooks.mjs:93` → `home/`. - `/run/media//` in the comments of `storageLocations.ts`, `storageLocations.test.ts` and `storageVolumes.ts`. - `/run/media/operator/` in the fixtures of `storageVolumes.test.ts` and `views/storage.test.ts`, on both sides of every assertion. - The one `editor/CHANGELOG.md` line (see "Changelog"). - **Step 8 had nothing to do.** `common/lib/envVars.ts` declares none of `SONG_DIR`, `VIDEO_ROOT` or `CHANNELS_DIR`. Its header says "umtool's own knobs are NOT here", and `envVars.test.ts:17` keeps umtool out of the scan. `ENVIRONMENT.md` is unchanged, and `--check` exits 0. The new defaults are documented where umtool's knobs live: a table under "Environment" in `umtool/docs/cli.md`. **Beyond the plan (all in `umtool/**`):** - **`SONG_REPORTS` moved into `song/paths.mjs`, beside `relTo`, and `lib/paths.mjs` re-exports it.** `make-thumb.mjs` needs it, and the song scripts import only siblings. The e2e fixture copies `song/*.mjs` into `.e2e-song/code/` and runs them from there, where `../lib` does not exist. There is still one definition, and the default is unchanged. - **`relTo(root, p)`.** An absolute `p` inside `root` becomes relative to it. Anything else comes back unchanged. Both sides are compared as given and through the realpath of their deepest existing part, so a path spelled through the `~/.local/share` symlink counts as inside the realpath'd `SONG_DATA`, even for a file not written yet. - **`accept-thumb.mjs` records `out` through `relTo(SONG_REPORTS, …)`.** It is the other writer of `thumb-accepted.json`, and `/api/browse/thumbs` hands it an ABSOLUTE `file`. Without this, the first accept from the page would have written a machine path back into the tracked file. The route's comment is updated to match. A relative CLI argument is recorded as typed, as before. - **The e2e fixture and a new assertion.** The fixture's accepted `alpha-c` carries the relative `out`, while its run log keeps the absolute form, so the suite reads both. deck.spec's "serves the ACCEPTED cover" now resolves a relative `out`. The disjoint-accept test asserts the CLI recorded `alpha-b` as `thumbs/alpha-b.jpg` (see "They bite"). - **Comments that described the old default:** - `song-capabilities.mjs` and `make-fixture.mjs` (twice) said "the job temp dir … which on most machines no longer exists"; - `browse.ts` said "`out` is an ABSOLUTE path". **Corrections to the plan:** - **The common baseline is 2,114, not 2,112.** O6c added 2 after the plan's re-check (`release-11.md`, O6c gates). - **Step 8 was a no-op**, as above. - **Step 4's `video-dir.mjs:137` is now `:139`**, because step 2 added two lines above it. **The one-off commands** (the scripts are in the job's `tmp`; what each does is stated here so it can be redone): - **Step 5:** `node $T/q-strip-vid.mjs umtool/song/um-manifest.json`. It runs `doc.items = doc.items.map(({ vid, ...rest }) => rest)` and writes `JSON.stringify(doc, null, 1)` with no trailing newline, which is the writer's format; a parse and re-stringify of the old file was byte-identical. It printed `stripped vid from 1896 of 3609 items`. - `git diff --numstat`: **0 added, 1,896 deleted**. - Every deleted line is `"vid": "file://…"`. - **Step 6:** `node $T/q-rel-thumbs.mjs "$HOME/reports/quartering-uh-song" "$HOME/.claude/jobs/efbe67a7/tmp/song" umtool/song/thumb-manifest.json umtool/song/thumb-accepted.json`. For every entry, an absolute `out` becomes relative to the first root and an absolute `bg` relative to the second. A path outside its root is refused, not guessed. The output format is the same as step 5. It printed `18 paths made relative` and `8 paths made relative`. - `git diff --stat`: **26 insertions, 26 deletions**, the `out`/`bg` lines only. **Verified: the same files and the same labels.** Run from `umtool/` with `SONG_DIR=/home/user/.claude/jobs/efbe67a7/tmp/song`, before and after the rewrite: - **`q-thumbs-check.mjs`** prints `resolveInRoots(out)`, `labelFor` and `browse.thumbFor`'s `SONG_REPORTS`-relative path for all 13 entries, plus the absolute `bg`. The two outputs are identical except the `CHANNELS_DIR` line, which is step 1: the worktree's own `transcripts/channels` instead of the primary's. Every `out` still resolves to `/home/user/reports/quartering-uh-song/thumbs/.jpg`, and all 13 exist. - **`q-thumbview.mts` runs the app's own readers** (`lib/thumbs.ts` `thumbView` and `lib/browse.ts` `thumbFor`) for yoshi, mario-rpg, metal-slug, mortal-kombat and pokemon. For "before", it pointed `SONG_CODE_DIR` at `90c7f776`'s two manifests. The output is **identical**: accepted names and labels, every candidate's label, the clash strings, `check` and the poster path. - **The one visible difference:** a candidate's `file` is now `thumbs/.jpg` instead of the absolute path, which changes the bench's grey text. The accept route resolves it to the same absolute file. - **The `SONG_DATA` default:** no env gives `/home/user/.local/share/archilyzer/song`, used as given because the path is absent. `SONG_DIR=` gives the job dir, and `SONG_SCRATCH` = `…/efbe67a7/tmp`. - **Nothing was created** under `~/.local/share` or `~/.config`. | sha | what | |---|---| | `c3177eeb` | `plans:` the source mirror plan (verbatim) and this record file | | `de1ead5d` | `umtool:` `REPO_ROOT` + `CHANNELS_DIR` from the checkout; `GLOBAL_CHANNELS_DIR` returns it | | `d4d549c7` | `umtool:` `SONG_DATA` (XDG default, realpath, missing → as given) and `VIDEO_ROOT` defaults; fixture comments; `docs/cli.md` defaults table | | `292f7a96` | `umtool:` the 20 run-log scripts deleted; `jobs.ts` / `BuildChain.tsx` in the past tense | | `4d12652e` | `umtool:` `um-manifest.json` without `vid` (one-off) and `build-um.mjs` strips it on write | | `73c39376` | `umtool:` thumb manifests relative (one-off); `relTo`; `make-thumb` / `accept-thumb` write relative; `SONG_REPORTS` into `song/paths.mjs`; the fixture's relative accepted cover | | `0b28a7a4` | `common, docs:` `/run/media/` / `operator`, `WORKTREES.md`, `fit-hooks.mjs` | | `29dc507c` | `changelog:` one `[Unreleased]` bullet; the released line's path example | | `1059befb` | `e2e:` deck.spec pins the relative `out` an accept records | | `57ce980a` | `plans:` this record; `source-mirror.md`'s "As shipped" note under Slice Q | | `75327c6f` | `changelog:` review L1 — the bullet leads with the full `mkdir -p … && ln -s …` command | | `0416881f` | `umtool:` review L3 — `ThumbEntry` declares `bg` (relative to the song data dir; `dataFile()`, never `resolveInRoots`) | | _this_ | `plans:` the review, and the operator step corrected (review I1) | **Gates** (from the worktree root; logs `$T/q-*.log`): - **The grep gate** `git grep -c -i 'user' HEAD -- . ':!plans/'` is **empty** (exit 1) at `29dc507c` and at `1059befb`. - **tsc** was clean before every commit (34–48 s; `q-tsc-{0..7}.log`, all seven packages). - **`node --check`** passed on all 11 changed `.mjs`. - **Unit:** - common **2,114/2,114**, the same count, with tests edited and none added (the three edited files 39/39); - `test:scripts` **185 + 1 skipped**; - editor unit **85/85**; - mcp **269/269**. - `pnpm archilyzer docs env --check` exits **0**. - `pnpm --filter umtool exec next build` is **ok** (20 s). - **The `lib/paths.mjs` print** from `umtool/` gives `/home/user/Projects/r12-paths-fix/transcripts/channels /home/user/.local/share/archilyzer/song`: this checkout's corpus and the XDG path. - **umtool e2e** (`faces.spec.ts deck.spec.ts clip-bench.spec.ts`, with `SONG_DIR=~/reports/quartering-uh-song/data`; the queue was free each time): - On `29dc507c`: **67 passed, 8 skipped, 0 failed** (2.0 min). - deck.spec alone with the new assertion: **14 passed, 2 skipped** (20.5 s). - On `1059befb`: **67 passed, 8 skipped, 0 failed** (1.6 min). - The skips are the song-data specs. This machine's `data/` has `cand2/` but no `wav48/`, `asr/` or `media/`, and `make-fixture` says so. - The fixture sets `CHANNELS_DIR` and `SONG_DIR` on both servers, so the green run is also the proof that env still wins. - **Numbers tool:** none. **They bite:** - deck.spec's disjoint-accept test fails with `90c7f776`'s `accept-thumb.mjs` swapped in: **1 failed**. It expected `thumbs/alpha-b.jpg` and received `…/umtool/.e2e-song/reports/thumbs/alpha-b.jpg`. A trap restored the file, and the tree was clean afterwards (`q-e2e-deck.log`). - The default changes have no unit tests; umtool has none for `lib/`. They are verified by the prints above. **Found and left:** - **Historical mentions of the deleted scripts stay, as the plan says:** - `debox-bg.mjs:24` (`pk3.sh`); - `hush-head.mjs:47` and `pick-take.mjs:26` (`mk-fatal-finish3.sh`); - `video-dir.mjs:5,139` (`pkmn-video.sh`). - **`cues.mjs`' `DEFAULT_CHANNELS_DIR` is evaluated inside the app bundle too.** `report.mjs` imports `build-video` and `resolve-windows`, which import `cues.mjs`, and there its `import.meta.url` walk points into `.next`. Only their CLI entry points read it, so nothing is wrong today. It is left alone by the plan. - **umtool now reads `TRANSCRIPTS_DIR`,** a declared core variable. `envVars.ts`' `readBy` for it does not name umtool, which is out of the registry's scope. I did not change it; it is not this slice's file. - **`REPO_ROOT` outside any checkout** falls back to the cwd's parent, as the plan says. Started from `/`, that is `/`. - **`bg` is now relative to the song data dir,** and nothing reads it yet. `ThumbEntry` declares it and says how to resolve it (review L3, below). **Changelog.** - **One `[Unreleased]` bullet** in `editor/CHANGELOG.md`. It leads with the change and the command a reader runs, `mkdir -p ~/.local/share/archilyzer && ln -s ~/.local/share/archilyzer/song`, before restarting umtool (review L1). Then: the corpus from `TRANSCRIPTS_DIR` or the checkout, the videos' default, relative manifests, and the run logs gone. - **The released `[0.9.0]` storage-locations entry now says `/run/media//`.** A released entry is normally left as written. This one was changed because it is a path example, not a name a reader would search for. **For the operator** (the plan's Rollout 0, the slice Q step). Do this **before any umtool restart**: ``` mkdir -p ~/.local/share/archilyzer && ln -s /home/user/.claude/jobs/efbe67a7/tmp/song ~/.local/share/archilyzer/song ``` - **What the link keeps (review I1).** The link target is the live :3050's current default `SONG_DATA`, and it holds **only the rebuildable `.cache/umtool`**: 7.9 MB of project index and caches, "safe to delete at any time" (`lib/paths.mjs`). The song project's bulk data is under `~/reports/quartering-uh-song/data`, not there. The link keeps :3050's `SONG_DATA`, `SONG_SCRATCH`, `CACHE_DIR` and `INDEX_DIR` byte-identical to today's (the job dir's realpath is its path). - **What a missed link costs:** an index rebuild, and the nesting trap. It loses no data. A restarted :3050 with no link finds nothing at the new default and may create `~/.local/share/archilyzer/song/.cache/umtool` as a real directory. A later `ln -s` would then put the link *inside* it (`song/song`). If that happened, remove the directory first. - **Linking to `~/reports/quartering-uh-song/data` instead** is a separate choice for the operator. It moves `CACHE_DIR` there and makes `SONG_SCRATCH` equal `SONG_REPORTS`. - **Before the restart,** the live :3050 is a `next start` build and keeps its baked paths, but it runs song scripts from disk: - the song build chain passes `SONG_DIR` explicitly (`lib/trim.ts:226`); - `accept-thumb` needs only `SONG_REPORTS`, whose default is unchanged; - a script run by hand with no `SONG_DIR` looks at the new default. **Review** (verdict **SHIP**; a read-only Opus review of `90c7f776..57ce980a`, `$T/q-review.md`). No High or Medium findings. The coordinator asked for two of the Lows to be fixed: - **L1 — fixed, `75327c6f`.** The changelog's bare `ln -s` failed on a machine with no `~/.local/share/archilyzer`. The bullet now leads with the full command, `mkdir -p ~/.local/share/archilyzer && ln -s ~/.local/share/archilyzer/song`. It names no machine path. - **L3 — fixed, `0416881f`.** A future reader could resolve the relative `bg` through `resolveInRoots`, which binds it to `SONG_REPORTS`. `ThumbEntry` now declares `bg?` with the rule: relative to the song data dir, resolve with `dataFile()`, never `resolveInRoots`. This is a type and comment only; no behaviour changed. - **Left, on the coordinator's word:** - **L2.** Run from a cwd outside any checkout, umtool's `REPO_ROOT` falls back to the cwd's parent and reads a corpus that is not there. This is the plan's design, and every documented invocation runs inside the checkout. A walk from `import.meta.url` as a second try is the follow-up. - **L4.** `cues.mjs` (`build-video`, `resolve-windows`, `check-availability`) still ignores `TRANSCRIPTS_DIR`. The plan leaves `cues.mjs` alone, and this is not a regression. - **L5.** With no song data, `SONG_SCRATCH` widens to `~/.local/share/archilyzer` in the read and write roots. Nothing else lives there today, and it was noted only. - **L6.** Cosmetic: a stale "job temp dir" comment in `lib/paths.mjs`, ragged comment wraps, and `docs/folders.md`'s older roots list. Not worth a commit here. - **I2, for slice R.** `29dc507c`'s commit message and older blobs carry `/run/media/user`, which the built-in `${os.homedir()}` rule does not match. R's `source-scrub.txt` needs its own rule, or its denylist refuses the publish (it fails closed). - **After the fixes:** tsc is clean (41 s, all seven packages), and the grep gate is empty at the new tip. Per the coordinator, e2e was not re-run for a type comment and a changelog line. ### Slice Q, found after rollout — the umtool build walked the corpus (2026-09-28) **What.** Slice Q's `umtool/lib/paths.mjs` set `CHANNELS_DIR` to `path.join(REPO_ROOT, "transcripts", "channels")`, with `REPO_ROOT = findRepoRoot(process.cwd())`. - **Turbopack evaluated that statically** as `[project]/transcripts/channels` and made it a directory asset reference. The walk's fallback, `path.resolve(start, "..")`, is the project root. - **In ``,** `pnpm --filter umtool exec next build` walked the real corpus (hundreds of GB, channel `data/` symlinked to another drive). It was OOM-killed twice, at about 3.7 GB RSS, so the live umtool on :3050 stayed down until this fix. - **Slice Q's gate built in a worktree,** which has no `transcripts/`, so the reference was empty and the build took 30 s. The hazard and the rule are in FACTS: "A path joined from `process.cwd()` is a directory of assets to Turbopack". **The repro.** Link the corpus into a worktree for the BUILD only, and cap memory: ``` ln -s /transcripts /transcripts timeout -s KILL 240 systemd-run --user --scope -q -p MemoryMax=5G -p MemorySwapMax=0 pnpm --filter umtool exec next build rm /transcripts ``` On `main` `10cefd15` it fails in 6.4 s (0.97 GB): `TurbopackInternalError: Failed to write app endpoint /page … [project]/umtool/lib/paths.mjs … ::resolve_reference failed … Symlink [project]/transcripts/channels//archive is invalid, it points out of the filesystem root`. **The fix** (branch `fix/umtool-build-trace` off `main` `10cefd15`). Every path or fs call on a value derived from `process.cwd()` or `import.meta.url`, in a umtool module the app imports, now opens with `/* turbopackIgnore: true */`. That is the per-expression opt-out Turbopack's own message documents. The calls are in: - `lib/paths.mjs` (`findRepoRoot`, `CHANNELS_DIR`); - `lib/paths.ts` (`SONG_CODE`, `stateFile`); - `lib/tools.mjs`, `lib/trim.ts`, `lib/report/driver.mjs`; - `report-to-video/brand.mjs` and `cues.mjs`. The values at run time are unchanged. From `umtool/`, `REPO_ROOT`, `CHANNELS_DIR` and `SONG_DATA` print the same as on `main`, and `CHANNELS_DIR` / `TRANSCRIPTS_DIR` / `SONG_DIR` still win. `os.homedir()` joins are left as they are. A build with `HOME` pointed at a synthetic home inside the project, holding out-of-root symlinks at every home-derived root, succeeded, so the tracer does not follow `os.homedir()`. | build (5 GB cap, `/usr/bin/time -v`) | result | wall | max RSS | `.next` | |---|---|---|---|---| | `main` `10cefd15`, corpus linked | **fails** (the error above) | 6.4 s | 0.97 GB | — | | fix, no corpus | ok | 42.2 s (a busy machine; 22.2 s on an earlier run) | 0.80 GB | 1,009 files, 25,417,403 B | | fix, corpus linked | ok | 24.2 s | 0.80 GB | 1,009 files, 25,417,285 B | - **The two outputs are the same file set,** differing only in the build-id directory. - **Nothing from the corpus is traced.** 0 `.nft.json` entries are under `transcripts/`. The 14 files that contain the string `transcripts/channels` are all `.js.map` source maps of the code; none is a chunk or an asset. **The guard: `scripts/umtool-build-trace.test.mjs`** (in `test:scripts`, 3 tests). It is a static, per-module check of umtool's app, components, lib, `report-to-video/*.mjs` and `song/paths.mjs`. A path or fs call carrying a value derived in that file from `process.cwd()`, `import.meta.url|dirname|filename` or `__dirname` must open with the opt-out. It costs about 0.1 s. With `main`'s `lib/paths.mjs` swapped in, it fails and names the defect: `umtool/lib/paths.mjs:118 : path.join(REPO_ROOT, "transcripts", "channels")`. | sha | what | |---|---| | `8346f824` | `umtool:` cwd-derived paths opt out of Turbopack's asset tracing | | `55699a20` | `scripts:` the guard | | _this_ | `plans:` this note, FACTS, the implementer rules' umtool build gate; the `[Unreleased]` bullet | **Gates:** - tsc is clean (all seven packages). - `node --check` passes on the five changed `.mjs`. - `test:scripts` **188 + 1 skipped** (`main` 185 + 1, plus the guard's 3). - The capped builds are in the table above. - umtool e2e (`faces`, `deck`, `clip-bench`, with the corpus link removed): **67 passed, 8 skipped, 0 failed** (1.9 min). The skips are the song-data specs, as in slice Q. **The other apps are safe by accident, not by rule.** - `common/lib/paths.ts`' `findMonorepoRoot()` falls back to `process.cwd()` (the app's own directory). - `homepage/app/lib/source.ts` joins `process.cwd()` + `public`. - Both build today, so there is no finding to fix, only a note in FACTS. **For the parent:** merge, then rebuild and restart :3050. That is the parent's step. ### Slice R, as shipped — `archilyzer source publish` + `/source` (2026-09-28) Branch `r12/source-mirror` off `main` `e6c5d2e3` (slice Q merged), worktree `~/Projects/r12-source-mirror` (worktree #10: editor 4001, homepage e2e 4040), one Opus implementer. Plan: [`source-mirror.md`](source-mirror.md), "Slice R", R1–R7. Why: the operator asked for the repo on the project site, read-only, clonable from static files, with a gate that refuses to publish a denied literal. `main` did not move during the slice (`git merge main`: already up to date). Scratch files `r-m-*` in the job's `tmp`. **What shipped.** - **R1, `common/publish/source.ts`: `publishSource(opts)`** — 0 published / skipped / checked, 1 refused or cancelled; a `SourceRefusal` is logged as `[source] REFUSED: …`. The steps are the plan's 1–17: - `git rev-parse` of the git COMMON dir's `refs/heads/main`; - the two operator files, parsed; a missing one is a refusal naming it (`~/`-relative); - the skip; - `resolveFilterRepo()`: `git filter-repo`, else `pipx run --spec git-filter-repo==2.47.0`, else the install line; - `git clone --no-local --bare --single-branch --no-tags --branch main` into `mkdtemp(/archilyzer-source-)`, origin removed; - filter-repo `--force --quiet --replace-refs delete-no-add --replace-text R --replace-message R`, then `filter-repo/` deleted; - `repack -a -d -q --max-pack-size=20m`, `prune-packed`, `pack-refs --all`, `update-server-info`. It refuses on loose objects, no `P` line, any ref but `refs/heads/main`, or a HEAD that is not main. - the object gate; the tree (`git archive` → `tar -x` → pages); the tarball (`--format=tar.gz -9 --prefix=archilyzer/`) and `snapshot.json` in the `Snapshot` shape; - the mirror staged from an allowlist; the manifest staged; the file gate; the limits; `--check` stops; the link-safe install (manifest removed first, written last); one summary line. Every child goes through `runChildIntoLog` with `AbortSignal.any([signal, timeout])`, in an environment with the `GIT_DIR`-family variables cleared. The one exception is the audit's binary `cat-file` stream (a `spawn`, with the signal). `clearPublishedSource` is what `--no-source` runs. `common/lib/sourceManifest.ts` is the leaf contract: `MIRROR_DIR`, `CLONE_URL`, `TREE_HREF`, `TARBALL_HREF`, `SourceManifest` and `parseSourceManifest`. - **R2, `sourceAudit.ts`.** - `parseDenylist` (`i:`, `#` comments, trimmed, deduped). - `scanBuffer`, and `redactHit`: ±24 bytes, every byte any hit covers masked, a half-shown occurrence included. - `maskLiterals` for every path or line quoted from the repo. - `auditObjects`: ONE `cat-file --batch-all-objects --unordered --batch` stream with a framing parser — blobs and commits/tags whole, trees by entry NAME (not their binary ids). - `auditFiles`: contents, paths, a `.gz` decompressed; a symlink is a refusal. - `runGitleaks`: cwd = scratch, so no checkout's ignore file applies. 0 is clean, 3 is parsed findings, anything else refuses; not on PATH is "skipped" with a WARNING. - `auditBare`: blob hits get their path in history, and gitleaks runs only when the literal audit is clean. - `formatAuditReport`: `#n (x…, len L)`, counts per literal and kind, the first 20 contexts, "… and N more", then the plan's closing line. - **R3, `sourceTree.ts`** (`hrefFor`, `escapeHtml`, `renderTreeIndex`, `writeTreeIndexes`, which refuses a tracked `index.html` or a symlink), and the `_headers` block — with the correction below. - **R4, the homepage.** - `app/lib/source.ts` `loadSourceManifest()` (also needs `info/refs` and the tarball). - Nav: Source after Docs. - `app/source/page.tsx`: the plan's copy verbatim; `source-clone`, `source-mirror-head`, `source-tree-link`, `source-tarball-link`, and `source-tarball-sha` for the spec; the empty state `source-empty`. - A shared `components/Fact.tsx`. - Downloads: one sentence linking "read-only git mirror", and the empty state names `archilyzer source publish`; every phrase `downloads.spec.ts` asserts is kept. - `marketing.spec.ts`' nav list gains Source. - **R5, the docs.** `create-archives.sh` is deleted. README and SETUP lead with the clone and keep the tarball as the no-git path. PUBLISH.md gains "The source mirror (homepage)". The homepage's *Install*, FAQ, *What is Archilyzer* and `content/README.md` stop saying "there is no public repository". `grep -rn 'create-archives' README.md SETUP.md homepage/` is empty. - **R6 and the rest.** - `getPaths()` gains `configDir`, `sourceScrubFile`, `sourceDenylistFile` and `sourceScratchDir`, from `ARCHILYZER_CONFIG_DIR`, `SOURCE_SCRUB_FILE`, `SOURCE_DENYLIST_FILE` and `ARCHILYZER_SOURCE_SCRATCH`, declared as `paths` in `envVars.ts`. - `HOMEPAGE_PUBLIC_DIR`'s `readBy` names `source.ts`, and `TRANSCRIPTS_DIR`'s doc says umtool reads `/channels` too (review Q(c)). `ENVIRONMENT.md` is regenerated. - The CLI rows `build homepage [--no-source]`, `source publish [--force] [--check] [--keep-scratch]` and `source audit []`. - `buildHomepage`: compose, then the source step (a lazy import; `publishSource` and `clearSource` are seams), then `next build`. - doctor's "source publish" block. It is never a failure; it WARNs when the operator files exist but no filter-repo is installed, or a file is readable by others. It prints rule counts and modes, never contents. **Corrections to the plan** (each found by running it): - **`_headers`: later rules APPEND, they do not win** (`f218ed86`). - wrangler 4.88's `attachHeaders` (the Pages asset server's code, read in its `cli.js`) `set`s a header on the first matching rule and `append`s it on every later one. The plan's exact text served a directory page as `text/plain; charset=utf-8, text/html; charset=utf-8` and a font as `text/plain; charset=utf-8, font/ttf`. - Each override now starts with `! Content-Type`. A replica of wrangler's parse and attach (`$T/r-m-headers-sim.mjs`) gives single values for `/source/tree/`, `…/common/`, a `.ts`, the bracketed `.ttf` and `page.tsx`, the `.onnx` and `README.md`. - The preview deploy is still the live proof. - **The tsconfig must exclude `out` too** (`3fa5ff18`). - `next build` copies `public/` into `out/`. The SECOND build with a mirror failed its type check on `out/source/tree/common/jobs/registry.ts`, a second `declare global var __yttJobRegistry__`. - With `out` excluded, the homepage program is 871 files, none from the mirror; homepage tsc takes 11 s with a mirror in both dirs. - The homepage's `eslint.config.mjs` ignores `public/source/**` (it already ignored `out/**`). Lint itself was not run. - **The published manifest carries no `rulesHash`.** - `plans/` ships in the mirror, and so does the plan's description of the two files. What is left unknown is the hostname and the address, so a published hash of the rules would confirm a guess at them. - The skip key (`{sourceCommit, rulesHash}`) is `homepage/.source-publish.json`, beside `public/` and gitignored. A missing key rebuilds; the round trip asserts it is absent from the manifest. - **The mirror has a loose `refs/heads/main` beside `packed-refs`.** git treats a directory as a repository only with a `refs/` dir, so without it the `file://` clone and `source audit` of the published dir fail. An empty dir would not survive a deploy. - **filter-repo's `--replace-text` does not skip `#` lines** (it would replace a comment as a literal; `get_replace_text` in 2.47). The step writes `replace.txt` without comments or blank lines. - **The step adds three safety flags:** `--no-tags`, `--replace-refs delete-no-add` (2.47's default is `update-no-add`, stated for determinism), and a check that the mirror holds exactly `refs/heads/main`. - **`--no-source` removes the published source** (manifest first; a linked `downloads/` goes as a link, its target untouched). It does not leave the source there: a copy audited against older rules would ship ungated. That removal gives the empty state R7 expects. - **The header nav moves from `sm` to `md`:** five labels do not fit beside the wordmark at 640 px. - **The tree has 4 `.ttf`**, not 3 (IBM Plex Mono Bold, O5); `*.ttf` covers them. - **No `branch` option:** `SOURCE_BRANCH = "main"`, an operator decision. `now` is `() => Date`. - **The packs are 37.5 MB in two** (20,897,277 + 16,633,904 bytes), not the plan's 21.5 MB in one. The history grew by about 150 commits since the plan, and the 20 MB split costs deltas across the two packs. That is well inside the limits. **The operator's files, as created, refuse the publish** (an action for the rollout, not the slice): - **The run:** `pnpm archilyzer source publish --check` with `~/.config/archilyzer/*` as the parent created them, at `main` `e6c5d2e3`. It ended `AUDIT REFUSED: 8 hits in 21,441 objects (1,702 commits) against 6 denied literals`, with every hit `#1 (r…, len 5)` in a blob: - `plans/source-mirror.md`, two versions, 3 hits each: the plan's own grep gates (`git grep -c -i '' …`, `git -C grep -c -i …`) and the "a future transcript quote " that"" risk line; - `plans/release-12.md`, two versions, 1 hit each: slice Q's grep-gate line. - **Why the scrub rules miss them.** They cover the backticked spelling and the `/run/media/` path (review I2: no hit came from there), not the bare, single-quoted or double-quoted name. - **The operator's step:** add a rule for the bare name (`==>user` covers every spelling, the backticked one included) or one per spelling. Then `archilyzer source publish --check` (about 25 s) must end `check passed`. - **How R7 ran anyway,** with the operator files untouched and the gate not weakened: - `SOURCE_SCRUB_FILE` = a scratch file of ONE rule, `==>user`, written with `$(id -un)`, with the REAL denylist. The check was clean: 4 literals, gitleaks clean. - `source audit` of both clones and of the published dir with the REAL files (6 literals) is clean, below. **Gates** (from the worktree root; logs `$T/r-m-*.log`). Every log of a run over the real files was first scanned by `$T/r-m-leakcheck.mts` (counts per literal label, any ASCII case) and read through `$T/r-m-maskview.mts`. The step's own lines carried 0 literals; the only hits were pnpm's and doctor's home-directory paths. - **tsc** was clean before every commit (`r-m-tsc-{0..3}.log`): - 215 s at the first run, 168 s, 59 s, and 73 s with a mirror in `public/` AND `out/`. Another session's tsc was running during the first and the last. - The homepage alone: 11 s, 871 files, none from the mirror. - **Unit:** - common **2,138/2,138** (2,114 + 24: sourceAudit 7, sourceTree 5, source 7, build +1, `_cli` +3, doctor +1). The two filter-repo tests RAN (`ok 1832` round trip, `ok 1833` planted-literal refusal); 0 skipped. - `test:scripts` **185 + 1 skipped**; editor unit **85/85**; mcp **269/269**; homepage unit **2/2**. - `pnpm archilyzer docs env --check` exits **0**. The editor's `next build` is **ok** (43 s); it bundles `buildHomepage`'s lazy import. - **`archilyzer build homepage`** (from `common/`, `SOURCE_SCRUB_FILE` as above): - **ok in 38 s**, against 19 s for `--no-source`. The source step took **19 s**: filter-repo 5.3 s, gitleaks 7.3 s, `[source] published main e6c5d2e322b3 as 78dc0126382b: 2459 files, 69.5 MB (mirror 2 packs, tree 412 dirs), tarball 6.9 MB sha256 c4ceb6d110ee`. - A rebuild with nothing changed logged `[source] up to date at e6c5d2e322b3; skipping`. - **Deterministic:** two runs gave the same `mirrorHead` and tarball sha; the real files' two runs gave `9b880270c49d` both times. - **What it published** (`homepage/out`): - **2,640 files, 78.1 MB** in all; `source/` 2,465 files, 65.8 MB; - the mirror: 9 files, 38.1 MB, 2 packs; - the tree: 2,035 files + 412 pages, 27.5 MB; - the tarball: 7,246,992 bytes. - **Against the limits:** 2,459 staged of the step's 15,000 (2,640 of Pages' 20,000); the largest file is 19.93 MiB, against 24 MiB (25 MiB). - **Two clones, both HEAD = `manifest.mirrorHead` `78dc0126382b…`:** - `git clone file://$WT/homepage/out/source/archilyzer.git`: 2 s. - `git clone http://127.0.0.1:8765/source/archilyzer.git` from `python3 -m http.server 8765 --bind 127.0.0.1` in `homepage/out`: 1 s. The protocol was dumb: `info/refs?service=…` 200, then `HEAD`, `objects/info/packs`, both `.idx` and both `.pack`; the loose-object probes 404. - `:8765` was free before, the server was killed, and it was free after. - **The user name:** `git grep -c -i -F "$(id -un)" $(git rev-list --all) | wc -l` is **0** in both clones (1,702 revisions). It is 0 in the identities and messages and 0 in the paths too; the hostname is 0. - **`archilyzer source audit`** with the real files (6 literals) is **clean** on the file clone (13 s), the http clone (11 s) and the published dir (10 s): 21,441 objects, 1,702 commits, gitleaks "1530 commits scanned … no leaks found". - **The gate, exercised:** `SOURCE_DENYLIST_FILE` = a scratch file of `Co-Authored-By`, then `source publish --check`: - exit **1** in 14 s, `AUDIT REFUSED: 1476 hits … #1 (C…, len 14): 8 in blobs, 1468 in commits`, 20 context lines and "… and 1456 more"; - the log holds the literal **0** times; - `public/source/manifest.json`'s mtime and size are unchanged (`1790616152 1057`). - **`build homepage --no-source`:** `out/source/index.html` holds `data-testid="source-empty"` and "No source published in this build."; `/downloads/` says "no source snapshot attached". - **No filter-repo:** with PATH = a scratch dir of two symlinks (`git`, `node`; nothing uninstalled), `git filter-repo` is "not a git command", and `source publish --check` exits 1 with `REFUSED: git-filter-repo is not installed and pipx is not on PATH — install it once: \`pipx install git-filter-repo\` …`. - **The resolved filter-repo** is `git filter-repo` (`~/.local/bin/git-filter-repo`, pipx-installed 2.47.0), whose `--version` prints `a40bce548d2c`; git is 2.55.0. The `pipx run` fallback was not run (it needs the network). - **`archilyzer doctor`** prints the block: ``` source publish ok filter-repo git filter-repo a40bce548d2c ok gitleaks gitleaks version is set by build process ok scrub rules ~/.config/archilyzer/source-scrub.txt (2 rules, mode 600) ok denylist ~/.config/archilyzer/source-denylist.txt (3 literals, mode 600) -- published main e6c5d2e322b3 as 78dc0126382b, 2026-09-28T17:22:29.301Z (2458 files) ``` The real output prints the absolute paths. `2458` is the manifest's `files`, which does not count the manifest itself. - **homepage e2e** (`node scripts/worktree.mjs run -- pnpm --filter homepage run e2e`; the queue was free): - **36 passed, 0 skipped, 0 failed, 1.1 min**, WITH the manifest present: `loadSourceManifest()` from `homepage/` gave mirror head `78dc0126382b`, so the published branch of every `source.spec` test ran. - The empty state too: with `public/source` and `public/downloads` moved aside and restored after, `source.spec` + `downloads.spec` + `marketing.spec` gave **16 passed** (30 s). - `downloads.spec.ts` is unchanged. - **Numbers tool:** none. **They bite:** - The planted-literal test fails if the object walk misses the blob. - The redaction test pins a half-shown second occurrence. - `build.test.ts`' fake refusal proves `next build` never runs. - The restricted-PATH run proves the install line. - The `Co-Authored-By` run proves the gate refuses a literal the scrub does not touch, in commits AND blobs. | sha | what | |---|---| | `25f5e241` | `homepage:` `/homepage/public/source` gitignored (the Tailwind note), tsconfig excludes `public` — first, before any mirror | | `7fdbe2dc` | `common:` `sourceAudit.ts`, `sourceTree.ts`, `sourceManifest.ts` + tests | | `fc31aab5` | `common:` `publishSource` (`source.ts`) + tests; `getPaths()` / `envVars.ts` / `ENVIRONMENT.md` | | `2f08cb47` | `common:` `buildHomepage` runs the step; the CLI rows; doctor's block + tests | | `1a11a2bb` | `homepage:` `/source/`, nav, `Fact`, Downloads, `_headers`, docs content, `source.spec.ts`, marketing nav | | `2cd189f3` | `docs:` README, SETUP, PUBLISH "The source mirror (homepage)"; `create-archives.sh` deleted | | `c448b392` | `changelog:` the editor and homepage `[Unreleased]` bullets | | `f218ed86` | `homepage:` `_headers` overrides detach `Content-Type` first (correction) | | `3fa5ff18` | `homepage:` tsconfig excludes `out`; eslint ignores `public/source/**` (correction) | | _this_ | `plans:` this record | **Found and left** (not this slice's files): - **`.dockerignore:20-22`** still names `create-archives.sh`, and it does not exclude `homepage/public/source/`. A `docker build` from a checkout that has published would send about 66 MB of mirror and tree in the build context. - **`common/lib/project.ts:37-38`** (`PROJECT_DOWNLOADS_URL`'s comment) says "there is no public git repository". - **The editor's /sites Homepage section** (`HomepageBuildButtons.tsx:170`) does not say that the build publishes the source, or that it can refuse. - **`export/CHANGELOG.md`'s released entry** names `create-archives.sh`. It is history, and is left. - **The label `#n (x…, len L)`** shows a literal's first character and length in the logs. This is the plan's format; nothing of it is published. - **gitleaks** scans 1,530 of 1,702 commits: its `git log -p` skips merges. The literal audit reads every object. **Changelog.** - **`editor/CHANGELOG.md`:** one `[Unreleased]` bullet. The build step and the gate; the operator files, and that the build refuses without them; `pipx install git-filter-repo`; the skip, the CLI rows and doctor; `create-archives.sh` gone. - **`homepage/CHANGELOG.md`:** one `[Unreleased]` bullet. `/source/` and its nav entry, the empty state, the tarball regenerated per build, the docs, the nav at `md`, and `_headers`. **For the parent and the operator:** 1. Add the scrub rule above, then run `archilyzer source publish --check`. 2. The editor's /sites homepage job finds `git filter-repo` only if the editor's PATH includes `~/.local/bin`. Otherwise it falls back to `pipx run`, which needs the network on first use. 3. The worktree's `homepage/public/source`, `homepage/out` and `homepage/.source-publish.json` were made with the scratch rule. They are disposable, and nothing here deployed them. **Review** (verdict **SHIP AFTER FIXES**; a read-only Opus review of `e6c5d2e3..6ddfd498`, `$T/r-review.md`). The parent then added the missing scrub rule to the operator file: `source publish --check` with the real files exits 0. The coordinator ruled that M1 and the listed Lows be fixed on the branch. `main` had moved by one `plans:` commit (the release 11 rollout record), merged at `439eb106` with no conflict. - **M1 — fixed** (`7d64054c`, and `78b32636` below). **A refusal left the previous publish in place**, in `homepage/public` and in the last build's `homepage/out`, for a deploy-only or a raw `next build` to ship under rules it was never audited against. Now it is withdrawn at three points: 1. **`publishSource`:** once the rules are loaded, any outcome but success — an audit hit, a limit, a missing tool, a cancel, a crash — runs `removePublishedSource`. That removes the manifest first, then the mirror, the tree, the tarball, `snapshot.json` and the skip key. `--check` still writes nothing, this included: it is a dry run, and the deploy check below covers what it cannot. 2. **`buildHomepage`:** a non-zero source result also removes `out/source` and the two download files from `homepage/out`. 3. **`deployHomepage` asks `publishedSourceProblem` before every deploy**, preview included. - An `out/` with source artefacts ships only when the skip key says the publish was made under TODAY's rules and step (`rulesHash`), of TODAY's `main`, and is the publish in `out/`: the same mirror head, and a tarball whose sha256 matches. - It keys on the artefacts and the `/source` page, not on `out/source` existing (`78b32636`). A `--no-source` build still renders the page into `out/source/index.html`, and the first version refused exactly that build; I found it by running one. - The page with no artefacts beside it is a `--no-source` build, and deploys as before. - No page at all is a refused build (or one from before the page), and it refuses. - **Tests:** the round trip denies a literal the mirror holds, and the publish then exits 1 with `public/source`, the tarball, `snapshot.json` and the key gone. The skip test covers a changed rule. `build.test` covers the `out/` removal and the deploy refusals. `publishedSourceProblem` is covered on a real publish: ok, tampered tarball, newer `main`, other rules, no record. - **Q3/L5 — fixed** (`6936f6e2`). A label says where the literal was written: `denylist line 3 (len 5)`, `scrub line 2 lhs (len 11)`, `built-in home rule (len 11)`. No character of the literal appears. - **L4 — fixed** (`6936f6e2`). - A hit is listed by kind, object id (with the blob's path in history), byte offset, and for a commit or tag the header field (`author`, `committer`, `tagger`, …) or `message`; a tree hit by entry number. `redactHit` is deleted: no byte of an object is printed. - The test asserts the report carries none of the planted text's neighbours (`/srv/`, `example.invalid`, `Planted <`, the message). - PUBLISH.md and the editor changelog show the new format. - **L1 — fixed** (`6936f6e2`): a tracked `404.html` anywhere in the tree is refused, with a test. - **L2 — fixed** (`7d64054c`). - A UTF-8 BOM and CRLF are dropped from both operator files (`operatorLines`), and a trailing `/` from the home dir. - An empty left side (`==>x`, `literal:==>x`, `regex:==>x`, `glob:`) is a refusal naming its line. - The reviewer's reproduction is a test: a BOM + CRLF scrub file still scrubs, and still denies its left side. The round trip now runs with a BOM + CRLF scrub file and denylist. - **L3 — fixed** (`7d64054c`): the published manifest (and `SourceManifest`) no longer carries `audit.literals`. The log still gives the count. - **L6 — left, as ruled:** compressed content is opaque to the byte search. The reviewer decompressed every compressed or binary blob in the history (a zip, PNGs, icons) and found 0 hits. PUBLISH.md records it as a known limit, and says a binary is audited, never scrubbed. - **L7 — fixed** (`d3c680ea`). `.dockerignore` excludes `homepage/public/source/` and `homepage/.source-publish.json`, and no longer names `create-archives.sh`. - **L8 — fixed** (`7d64054c`), for a checkout with no git repository. - `buildHomepage` passes `noRepository: "empty"`. The step logs `[source] no git repository here; nothing to mirror — the /source page will show its empty state`, removes an old publish, and returns 0. - `archilyzer source publish` exits 1 with the same sentence, and no raw git error. - A test runs both over a temp dir with no operator files. - **L9 — fixed** (`7d64054c`, `91728a21`). - The round trip reads EVERY object in the published packs from a plain file copy (`cat-file --batch-all-objects`), and asserts `objects/pack` holds only `pack-*.{pack,idx}`. - `homepage/app/lib/headers.test.ts` pins `_headers` with a replica of wrangler's parse and attach: - each `/source/tree` override says `! Content-Type` first; - each path gets ONE value; - the file stays inside wrangler's limits. - `E2E_EXPECT_SOURCE=1` makes the empty state FAIL `source.spec.ts`' three data tests. It is declared in the homepage playwright config and the env registry. It bites: in the empty state with the flag set, **3 failed, 2 passed**. - **L10 — fixed** (`7d64054c`, `91728a21`). `parseSourceManifest` checks every number the page reads, and `loadSourceManifest` takes the public dir as a seam. Unit tests cover common (2) and homepage (2): a malformed, wrong-version or orphaned manifest is null, never a throw. - **L11 — fixed** (`7d64054c`). - `SOURCE_STEP_VERSION` (now 2, with a comment to bump it whenever the scrub or the audit changes) is in the rules hash. - The skip key also holds the filter-repo label and version, and the mirror head. - A test shows another filter-repo version does not skip. - **L12 — fixed** (`7d64054c`). - A scratch root that lands (through symlinks) inside the checkout or the public dir is refused. - `--keep-scratch` deletes `replace.txt` (written mode 600) and says so. - Tests cover both. - **L13 — fixed** (`d3c680ea`). - The `project.ts` comment names the mirror. - PUBLISH.md's tsconfig line says `public` and `out`. The "new spelling" line now says the implied denial is the exact bytes, and a new spelling is caught only by the denylist. - The editor's /sites Homepage section gains one `

`: Build also publishes the source and can refuse; a refusal removes it from `homepage/out` too; Deploy refuses an unaudited source. I did not run the editor e2e for it: - `sites-homepage.spec.ts`' assertions on that group are `toContainText` substrings of the existing paragraph, role-and-name lookups for buttons, and `getByText(/^Queued/)` and `"Cancelled"` (exact). A new sibling paragraph cannot break any of them. - Its build job is held on the queue and cancelled, so the source step never runs there. - **The runbook point (PUBLISH.md, `d3c680ea`):** - a Pages preview is public, and every deployment stays reachable at its hash URL until it is deleted; - the private literals go in the denylist before ANY deploy; - if something private ships, delete that deployment, because a newer deploy does not remove it; - the editor's process needs `~/.local/bin` on its PATH. - **The five questions, as ruled:** 1. The rules hash in the gitignored `.source-publish.json` is **acceptable**. The step version has been added (L11). 2. `--no-source` removing the previous publish is **right**. 3. The first character is **dropped** (above). 4. The loose `refs/heads/main` is **acceptable**. 5. `.dockerignore` is **fixed here** (L7). | sha | what | |---|---| | `439eb106` | merge `main` (the release 11 rollout record) | | `6936f6e2` | `common:` labels by provenance, no object bytes in the report, `404.html` refused (Q3, L4, L1) | | `7d64054c` | `common:` a refusal withdraws; the build's `out/` copy; the deploy check; L2, L3, L8, L10, L11, L12 | | `91728a21` | `homepage:` `E2E_EXPECT_SOURCE`; the loader and `_headers` unit tests (L9, L10) | | `d3c680ea` | `docs:` PUBLISH.md (withdrawal, previews, no-repo, report format, L6); `.dockerignore`; `project.ts`; the /sites copy; the changelog bullet | | `78b32636` | `common:` the deploy check keys on the artefacts and the `/source` page (found by running `--no-source` against it) | | _this_ | `plans:` this review record and `source-mirror.md`'s "As shipped" note for R | **Gates after the fixes** (logs `$T/r-m-*.log`): - **tsc:** clean before every commit (36–55 s). - **Unit:** - common **2,146/2,146** (+8: sourceAudit +2, source +3, build +1, sourceManifest +2). The filter-repo tests ran, 0 skipped. - homepage unit **7/7** (+5); editor unit **85/85**; `test:scripts` **185 + 1 skip**. - `docs env --check` exits **0**. - **`source publish --check` with the REAL files** exits **0** in 19 s: - `audit clean: 21,447 objects (1,703 commits), 2,459 staged files against 6 denied literals; gitleaks clean`; - it would publish main `e56101fdee5d` as `20c367613f75`; - a count-only grep of the log for `$(id -un)` gives **0**. - **A real `build homepage` in the worktree, with the real files:** - **ok in 38 s** (the source step 19 s); `homepage/out` holds **2,640 files, 77.9 MB**; - the mirror is 37.9 MB in 2 packs, the largest 20,966,235 bytes (19.99 MiB, against the 24 MiB limit); the tree is 2,035 files and 412 dirs; the tarball 7,249,703 bytes. - **Dumb-HTTP clone** from `python3 -m http.server 8765 --bind 127.0.0.1` in `homepage/out`: - HEAD `20c367613f75…` = `manifest.mirrorHead`; - the user-name grep over its 1,703 revisions gives **0**; - `:8765` was free before, the server was killed, and it was free after. - **The refusal, exercised through `build homepage`** (`SOURCE_DENYLIST_FILE` = a scratch file of `Co-Authored-By`): - exit **1** in 13 s: `1477 hits … denylist line 1 (len 14): 8 in blobs, 1469 in commits`, with each listed as `commit (message, byte N)`; - the log holds the literal **0** times, and the source step's lines hold 0 of the 6 real literals; - afterwards `public/source`, the public tarball, the skip key, `out/source` and the out tarball are **all gone**, and `out/downloads/index.html` stays; - a good rebuild restored them all, and the deploy check then says "ok". - Before the refusal, the deploy check over the real `out/` said ok under the real rules, and "audited under other rules" with the scratch denylist. - **`build homepage --no-source`:** `out/source` holds only the page. The tarball is gone, and the deploy check says ok. - **homepage e2e with `E2E_EXPECT_SOURCE=1`:** **36 passed**, 0 skipped, 51 s, with the manifest present. The bite run is above: 3 failed and 2 passed in the empty state. **Re-review** (verdict **SHIP**; `$T/r-review.md`, "Re-review"). M1 is confirmed closed, and every deploy path goes through the check. The coordinator ruled that `--check` not withdrawing is accepted as built, and that the four new Lows be closed before the merge: - **R2-L1 — fixed** (`831763da`). A refusal that names a tree path (the symlink, `index.html` and `404.html` refusals) printed a literal that spans path components (`a/b`) in full. The object walk reads entry names one at a time, so it cannot see such a literal. - `[source] REFUSED: …` now goes through `maskLiterals` with the loaded literals, in both `publishSource` and `auditSource`. - The kept-scratch path, the report's scrub-file path and the audit's "auditing

" line are masked too. - Child stderr was already masked wherever it is quoted or echoed: git's refusal tails, filter-repo's echo, gitleaks' lines. `subject` comes from the scrubbed mirror. - The test plants `plant/secret` as a denied literal above a tracked symlink, then above a tracked `index.html`. Both refusals print `[REDACTED]/…`. - **R2-L2 — fixed** (`831763da`). The state carries `contentDigest`, from `sourceDigest()`: - it covers every published file: `source/archilyzer.git/**`, `source/tree/**`, `source/manifest.json`, the tarball and `snapshot.json`; - it hashes the sorted path, the size and a streamed sha256 of each; a symlink or a missing piece only makes it differ; - the skip recomputes it over `public/`, and the deploy check over `out/`. A swapped tree file and a flipped pack byte are refused. On the real `out/` (2,640 files) the whole check takes **0.58–0.76 s**, of which the digest is **0.48–0.54 s** (three runs). - **R2-L3 — fixed** (`831763da`). - **The skip:** each part of the key (filter-repo, gitleaks, content digest, rules) and an edited `public/` file is changed alone, through a publish that REACHES the skip. The filter-repo is `false`, so a skip returns 0 and a miss returns 1. - **The step version:** `rulesHashOf` is shown to move with it, and `loadSourceRules` uses `SOURCE_STEP_VERSION`. - **The deploy check's mirror-head and gitleaks comparisons** each refuse with their own sentence. - **Proved by reverting each fix** (`$T/r-m-mutate.py` against `source.ts`, restored after): every revert fails its test. The eight reverts are: - the deploy check's mirror-head comparison; - the step version in the hash; - filter-repo, gitleaks and the digest in the skip key (three reverts); - gitleaks and the digest in the deploy check (two reverts); - the refusal masking. - **R2-L4 — fixed** (`831763da`). - The state carries `gitleaksIdentity()`: "skipped", "absent", or the version line plus the binary's sha256. The sha is there because this machine's gitleaks prints "version is set by build process" for every release. - The skip and the deploy check compare it. - `SOURCE_STEP_VERSION` is 3. - **The rollout note** (PUBLISH.md, `7940cb0c`, and here): **after the merge, rebuild and restart the live editor (:3001) before any /sites Homepage job.** It runs its built bundle, so until then its Build homepage job has no source step and its Deploy homepage job no source check. - **The reviewer's "minor" is left:** a real checkout that unexpectedly reads as "not a git repository" (a worktree whose primary moved) builds with the empty state, withdrawing the source. It is safe for privacy, and it is logged. | sha | what | |---|---| | `831763da` | `common:` refusals masked; `contentDigest` and `gitleaksIdentity` in the key; step version 3; the key's tests bite (R2-L1…L4) | | `7940cb0c` | `docs:` PUBLISH.md — the deploy key, masking, and rebuilding the editor after the merge | | _this_ | `plans:` this re-review record | **Gates after the re-review fixes:** - **tsc:** clean, 35 s. - **Unit:** common **2,149/2,149** (+3); the three filter-repo tests ran, 0 skipped. Homepage unit **7/7**. `docs env --check` exits 0. - **`source publish --check` with the real files** exits **0** (19 s, would publish mirror head `20c367613f75`). The count-only user-name grep of its log gives **0**. - **A real `build homepage`** is ok in 38 s (18 s for the source step). The source step's lines hold 0 user-name occurrences. - **The deploy check, called read-only** on that `out/`, says **ok (would deploy)**. - **A rebuild with nothing changed** logs `up to date … skipping` (20 s), and the check still says ok. - Nothing was deployed. `main` had not moved. ## Rollout **Merged** (by the parent, in the primary, `git merge --no-ff` on a clean tree): - **Slice Q** merged as `4855f70b`, then the changelog fix `e6c5d2e3`. The 0.10.0 cut landed between Q's branch point and its merge. A clean textual merge filed Q's bullet inside the released `[0.10.0]` section, and `e6c5d2e3` moved it back under `[Unreleased]`. - **Slice R** merged as `ffdeb2cd`. The tree is identical to R's tip `484952ed`. - **The parent then prepared the operator's side:** - it created `~/.config/archilyzer/` (mode 700) with the two files (mode 600): the scrub file holds **3 rules**, the denylist **3 literals** — the Unix user name, the host name and one email address. The contents are never printed; - it installed `git-filter-repo` 2.47.0 with pipx (`~/.local/bin`); - it ran `pnpm archilyzer source publish --check`: **exit 0**, it would publish main `e56101fdee5d` as `20c367613f75`, with 2,459 files, 69.3 MB, 2 packs, 412 tree dirs and a 6.9 MB tarball. - The runbook's `r12-check.sh`, run in the primary while this record was written with `main` at `ffdeb2cd`, also exits **0**. It would publish `ffdeb2cd770e` as `8188e02a7d04`, with 2,473 files, 69.8 MB, 2 packs, 413 tree dirs and a 7.0 MB tarball; its log holds 0 user-name and 0 host-name occurrences. - **Nothing is rolled out.** Nothing was deployed. The live :3001 editor runs 0.10.0 (`BUILD_ID` `vWCJb87ktCy5akih_pM9X`, built on `e6c5d2e3`): it has no source step, no withdrawal and no deploy check. The primary's `homepage/out` predates release 12 (it has no `/source` page), so the deploy check refuses it until it is rebuilt. - **A side effect of the scrub, cosmetic and in the mirror only:** the bare user name is scrubbed to `user`. The `plans/` text in the mirror (the grep gates, one risk sentence) therefore reads differently from the private repository. **What is OWED, in order.** The operator's runbook is `~/reports/release-12/RUNBOOK.html`, rendered by `~/reports/release-12/make-runbook.py`. Its scripts are in `~/reports/release-12/scripts/` and log to `~/reports/release-12/tmp/`. Every script refuses unless the primary's `HEAD` contains `ffdeb2cd`. **Before ANY deploy, a preview included, the denylist must hold everything private.** A Pages preview is public, and every deployment stays reachable at its own `.archilyzer.pages.dev` until it is deleted. 0. **The operator completes the denylist**, then runs the check. Add your real name, other handles and anything else that must never appear to `~/.config/archilyzer/source-denylist.txt`: one per line, `i:` for any case. Then: ``` cd ~/Projects/yt-dlp-transcript-browser && pnpm archilyzer source publish --check ``` (or `sh ~/reports/release-12/scripts/r12-check.sh`, which also counts user-name occurrences in its log: expect 0). - **Expect:** `[source] audit clean: …` and `[source] check passed — would publish main <12 hex> as <12 hex>: 2459 files …; nothing written`. - **If it refuses:** the report names the source only by position (`denylist line N (len L)`) and each hit only by object, field and byte offset. Add a scrub rule to `~/.config/archilyzer/source-scrub.txt` (`==>user`), or drop the file from history. Then re-run. 1. **The song link, before any umtool restart** (slice Q's operator step, with the review's I1 correction): ``` mkdir -p ~/.local/share/archilyzer && ln -s ~/.claude/jobs/efbe67a7/tmp/song ~/.local/share/archilyzer/song ``` - The target holds only umtool's rebuildable `.cache/umtool`. The song project's bulk data is in `~/reports/quartering-uh-song/data`; pointing the link there instead is a separate choice. - `~/.local/share/archilyzer` did not exist on 2026-09-28. If a restarted :3050 created `…/song/.cache` as a real directory first, remove that directory before linking, or the link lands inside it. 2. **Rebuild and restart the live editor, with `~/.local/bin` on its PATH.** The precedent is release 11's `r11-build.sh` + `r11-restart.sh` in `~/reports/overnight-2026-09-28/scripts/`. Release 12's copies are `r12-build.sh` and `r12-restart.sh`: - `r12-build.sh` builds into the live `.next`. Run the restart right after it. - `r12-restart.sh` refuses while `BUILD_ID` is still `vWCJb87ktCy5akih_pM9X`. It starts the editor with `PATH=$HOME/.local/bin:$PATH` and checks that the new server's environment has it. Without it, `/sites` Build homepage falls back to `pipx run`, which needs the network. - Run the md5 sweep around the restart, and the smoke after it. `r12-md5.sh` and `r12-smoke.sh` wrap `plans/tools/rollout/md5.sh` and `smoke.sh` with `CLAUDE_JOB_DIR=~/reports/release-12 REL=r12`: ``` sh ~/reports/release-12/scripts/r12-md5.sh before sh ~/reports/release-12/scripts/r12-build.sh sh ~/reports/release-12/scripts/r12-md5.sh pre-restart sh ~/reports/release-12/scripts/r12-restart.sh sh ~/reports/release-12/scripts/r12-md5.sh after-boot sh ~/reports/release-12/scripts/r12-smoke.sh cd ~/Projects/yt-dlp-transcript-browser && pnpm archilyzer doctor ``` - **Expect:** `BUILD_OK`, then `RESTART_DONE editor / 200`, `/sites` carries release 12's sentence ("also publishes the source mirror") and the editor's PATH has `.local/bin`. `r12-md5.sh after-boot` ends `MD5_SAME`, and the smoke ends `SMOKE_FAIL=0`. A `PAIR_DIFF` on a pair that moves live (auto-queue status) is drift, as it was at release 11. - **Doctor** has a **source publish** block: - `filter-repo git filter-repo a40bce548d2c`; - `gitleaks` ok; - `scrub rules … (3 rules, mode 600)` and `denylist … (N literals, mode 600)`; - `published` says nothing is published in the primary yet. 3. **A FRESH homepage build in the primary.** The deploy check refuses any `out/` built before release 12, because it has no `/source` page. ``` sh ~/reports/release-12/scripts/r12-home-build.sh ``` - It runs `pnpm archilyzer build homepage`, then checks `homepage/out/source/archilyzer.git/info/refs`, the file count and the deploy check, read-only. - **Expect:** `[source] published main … as …: 2459 files` (about 19 s for the step), `out: ~2,640 files`, `info/refs: <40 hex>\trefs/heads/main`, and `deploy check: ok (would deploy)`. - **If the build refuses:** it has already WITHDRAWN the source from `public/` and `out/`. Fix the rule (step 0) and rebuild. 4. **The preview** (`source.archilyzer.pages.dev`; public): ``` sh ~/reports/release-12/scripts/r12-preview.sh sh ~/reports/release-12/scripts/r12-live-check.sh https://source.archilyzer.pages.dev ``` - The first script runs `pnpm archilyzer deploy homepage --preview source`, which asks the deploy check first. Its log ends `[preview] https://source.archilyzer.pages.dev (this deployment: https://.archilyzer.pages.dev)`. - The live check is `source-mirror.md` Rollout step 2, as commands. Each line prints OK or FAIL: - `git clone https://source.archilyzer.pages.dev/source/archilyzer.git` works; - the clone's HEAD = `manifest.json`'s `mirrorHead`; - `…/source/tree/common/lib/paths.ts` is `content-type: text/plain; charset=utf-8`, ONE value, with `x-content-type-options: nosniff`; - `…/source/tree/common/` is `text/html; charset=utf-8`, ONE value. The appended form `text/plain…, text/html…` is the bug `f218ed86` fixed; - `…/source/tree/umtool/report-to-video/fonts/Archivo%5Bwdth%2Cwght%5D.ttf` is 200 `font/ttf`; - `…/source/tree/homepage/app/docs/%5Bslug%5D/page.tsx` is 200 `text/plain; charset=utf-8`; - the tarball's sha256 = `snapshot.json` = `manifest.json` = the one on `/source/`; - `pnpm archilyzer source audit /.git` is clean; - user-name and host-name counts in the clone's history are 0. 5. **Production:** ``` sh ~/reports/release-12/scripts/r12-prod.sh sh ~/reports/release-12/scripts/r12-live-check.sh https://archilyzer.pages.dev ``` - The first script runs `pnpm archilyzer deploy homepage`: branch `main`, the log ends `[deployed] https://.archilyzer.pages.dev`. - Or use the editor's path, which also proves the step inside its job: `pnpm ops build-homepage --json '{"deploy":true}' --wait` with `WORKER_TOKEN` from `editor/.env`. - The same checks must pass on `archilyzer.pages.dev`. 6. **If the edge refuses the dumb clone** (`source-mirror.md` Rollout step 4): the tree and the tarball still stand. The mirror would need another host (R2 behind a custom domain, out of scope). Record it, do not improvise. 7. **If something private ever ships:** - DELETE THAT DEPLOYMENT in the Cloudflare dashboard (Workers & Pages → `archilyzer` → Deployments). A newer deploy does not remove it; for a preview, delete every deployment on that branch. - Then add the literal (and a scrub rule) and run `pnpm archilyzer build homepage`, which refuses and withdraws. Rebuild clean, and deploy again. 8. **Housekeeping:** - `pnpm archilyzer release show` has 2 editor bullets pending; export has none, so `all` would refuse. When the operator chooses: `pnpm archilyzer release cut editor next --commit` (0.10.1) or `next-minor` (0.11.0). - Remove the worktrees when done: `pnpm wt rm r12-paths-fix` and `pnpm wt rm r12-source-mirror`, from the primary. Removing them re-sorts the index-based port blocks, so do it only when no dev server or e2e runs in any worktree; the parallel session's `r13-*` worktrees are active. The seven `r11-*` wait on these. - Optional: `git branch -d r12/paths-fix r12/source-mirror`.