diff --git a/.agent/phases/complete/21_thinking_no_scroll/02_e2e_story_suite_commit.md b/.agent/phases/complete/21_thinking_no_scroll/02_e2e_story_suite_commit.md
deleted file mode 100644
index e92d2c7..0000000
--- a/.agent/phases/complete/21_thinking_no_scroll/02_e2e_story_suite_commit.md
+++ /dev/null
@@ -1,70 +0,0 @@
-# Task 02 — E2E story suite, story file, validation, commit
-
-**Phase:** `21_thinking_no_scroll` · **Source:** `TODO.md` L4
-
-## Objective
-The story gate: `tests/e2e/test_thinking_no_scroll.py` proves the window
-can't be user-scrolled but always tracks the live tail, plus regressions,
-story file, final validation, and the single atomic commit.
-
-## Work
-1. `tests/e2e/test_thinking_no_scroll.py` (new — reuse
- `test_thinking_display.py`'s mock-LLM streaming scaffolding; the mock
- must stream a **long** thinking body, in many chunks, so
- `.thinking-text` overflow exceeds its 320px box). Tests:
- 1. `test_thinking_window_not_user_scrollable` — open the block, wait
- until `scrollHeight > clientHeight`; focus `.thinking-text`
- (`el.focus()`), dispatch mouse wheel over it
- (`page.mouse.wheel(0, -200)` after moving the mouse over the
- element) and press `Home`/`ArrowUp`: `scrollTop` must not decrease
- (assert `scrollTop` unchanged within 1px between actions).
- 2. `test_thinking_window_tracks_live_tail` — while chunks stream,
- after the 2nd-to-last and last chunk:
- `scrollTop === scrollHeight` (within 1px) — the visible window is
- the live tail; the **last** chunk's text is within the visible
- rectangle (its offsetTop + scrollTop geometry check, or
- `elementFromPoint` at the box's bottom).
- 3. `test_thinking_window_css_contract` — computed style of
- `.thinking-text`: `overflow-y === "hidden"`,
- `max-height === "320px"`.
- 4. `test_answer_bubble_still_scrollable` (regression, phase 11) — a
- long answer (use the long-answer mock from
- `test_long_answers.py`): the answer bubble is still
- user-scrollable (scrollTop moves on wheel) and
- `overflow-y` is not `hidden` there.
- 5. `test_restored_collapsed_thinking_unaffected` (regression,
- phase 17) — a turn with stored `thinking`, reload: the collapsed
- Thinking block renders with its text (existing pin from
- `test_thinking_display.py` — replicate, don't duplicate the file).
-2. `.agent/user_stories/thinking-no-scroll.md` (new) — story file per
- the repo format: goal, the bug report verbatim from `TODO.md` L4, the
- owner-confirmed A2 decisions from `00_phase.md`, E2E mapping table.
-3. Run the suite **in isolation** (prereq `podman compose up -d db`):
- `uv run pytest tests/e2e/test_thinking_no_scroll.py -v --no-cov`.
-4. Regressions, in isolation, one command each:
- - `uv run pytest tests/e2e/test_thinking_display.py -v --no-cov`
- - `uv run pytest tests/e2e/test_long_answers.py -v --no-cov`
-5. Final validation: `uv run pytest` green; `uv run pytest --cov=app
- --cov-report=term-missing` ≥ today's number (>90% gate);
- `uv run ruff check . && uv run pyright` clean.
-6. **UI Structure Check** (AGENTS.md rule 5): no new surface; the block
- keeps its summary chevron, focus-visible ring, aria-live/label
- contract, and the reduced-motion stillness (styles.css ~line 686).
-7. Write the phase report (`.agent/reports/21_thinking_no_scroll/`).
-8. Commit (one atomic commit) and move the phase:
- ```bash
- git add -A .agent/ frontend/ tests/
- git commit --no-gpg-sign -m "fix(ui): thinking window no longer scrolls — live 320px view pinned to the stream tail"
- mv .agent/phases/todo/21_thinking_no_scroll .agent/phases/complete/
- ```
-
-## Testing & Quality
-- Story suite green **in isolation**; both regression suites green in
- isolation; full unit+integration suite green; `app/` coverage at or
- above today's number (>90%); ruff + pyright clean.
-
-## Completion Criteria
-- [ ] `test_thinking_no_scroll.py` 5/5 in isolation.
-- [ ] Regressions (thinking display, long answers) green in isolation.
-- [ ] Story file + phase report exist.
-- [ ] One `--no-gpg-sign` commit; phase directory in `complete/`.
diff --git a/.agent/phases/complete/22_background_animation/01_fix_background_animation.md b/.agent/phases/complete/22_background_animation/01_fix_background_animation.md
deleted file mode 100644
index 22b9e41..0000000
--- a/.agent/phases/complete/22_background_animation/01_fix_background_animation.md
+++ /dev/null
@@ -1,60 +0,0 @@
-# Task 01 — Diagnose and fix the animated background (styles.css)
-
-**Phase:** `22_background_animation` · **Source:** `TODO.md` L5 —
-*"Fix background animation not working, just blinking"*
-
-## Objective
-Find why the phase-08 animated background reads as "just blinking" and
-fix `styles.css` so the grid drift and the glow breathe are both visibly
-and smoothly alive, per the phase-08 design comments (pure CSS, zero JS).
-
-## Work
-1. **Reproduce:** `uv run uvicorn app.main:app --reload` (db up), open `/`
- in a visible Chromium window (Playwright or the interactive browser),
- observe ≥15s. Note exactly what moves and what doesn't.
-2. **Diagnose per the `00_phase.md` checklist** (per-layer visibility
- toggles, `background-position` samples on `body::before`, mask
- inspection, occlusion check against `html`/`body` rules, glow
- opacity-swing perception). Record findings + before-screenshot in
- `.agent/reports/22_background_animation/` and
- `.agent/screenshots/22_background_animation/`.
- **ASSUMPTION (to verify, not assume):** the likely culprits, in
- order — (a) the masked grid drift is too faint/slow to perceive,
- (b) only the glow opacity swing is visible and it reads as a blink,
- (c) a later rule occludes the `z-index: -1` layers. Confirm which
- one actually fires before touching CSS; the fix must match the found
- cause.
-3. **Fix in `frontend/assets/styles.css`** (smallest change that makes
- the design read):
- - grid: raise line alpha and/or the mask's visible radius and/or the
- drift speed as needed for a clearly visible, seamless drift
- (drift delta must still equal one 44px cell for a seamless loop —
- if the speed changes, keep `background-position` 0→44px and only
- move the duration);
- - glow: if the breathe reads as a blink, narrow the opacity delta
- (e.g. 0.8↔1) and/or lengthen the period — it must read as
- breathing, not pulsing;
- - keep: both layers `position: fixed; inset: 0; z-index: -1;
- pointer-events: none`; no `filter: blur`; no JS; palette/contrast
- untouched.
-4. **After-screenshot** (same viewport, two frames a few seconds apart
- showing motion) into the same screenshots dir.
-5. `tests/unit/test_background_animation.py` (new — repo source-pin
- pattern): pin the **final** `styles.css` values — both
- `@keyframes` present, `body::before` → `bg-grid-drift linear
- infinite`, `body::after` → `bg-glow-breathe`, both layers
- `fixed`/`z-index: -1`/`pointer-events: none`, `html` keeps
- `background: var(--bg)`, `body` keeps `background: transparent`.
-6. Manual re-verify: the "just blinking" perception is gone — smooth
- drift + gentle breathe, no jank, no static frame.
-
-## Testing & Quality
-- `uv run pytest tests/unit/test_background_animation.py -v` green.
-- `uv run ruff check . && uv run pyright` clean.
-
-## Completion Criteria
-- [ ] Root cause documented (with before/after screenshots) in the
- phase report dir.
-- [ ] Both layers visibly animate as the phase-08 design describes;
- pure CSS, zero JS, no blur.
-- [ ] Unit pins green against the final values; lint/types clean.
diff --git a/.agent/phases/complete/23_containerfile_build/00_phase.md b/.agent/phases/complete/23_containerfile_build/00_phase.md
deleted file mode 100644
index 3160eec..0000000
--- a/.agent/phases/complete/23_containerfile_build/00_phase.md
+++ /dev/null
@@ -1,120 +0,0 @@
-# Phase 23 — Containerfile: Build the Whole App Image Again
-
-**Source:** `TODO.md` L6 — *"Fix Containerfile build not working"*
-**Story:** `.agent/user_stories/containerfile-build.md` (created by task 02)
-**Context:** `Containerfile` (3 stages: node:22-alpine + esbuild
-0.25.5 frontend bundle → uv/python deps → slim runtime serving
-`/app/static`); `frontend/` (4 pages: `index.html`, `sources.html`,
-`document.html`, `login.html`; assets: `styles.css`, `markdown.js`
-(classic script), `header.js`/`app.js`/`sources.js`/`document.js`/
-`login.js` (ES modules)); `scripts/entrypoint.sh`.
-
-## Verified diagnosis (2026-08-24, this conversion — not a guess)
-1. **Root cause of the build failure:** phase 19 switched the page
- scripts to `import … from "/assets/header.js"` (an absolute URL).
- esbuild resolves that as the *filesystem* path `/assets/header.js`
- and the stage-1 bundle dies:
- `✘ [ERROR] Could not resolve "/assets/header.js"`
- (reproduced with esbuild **0.25.5**, the exact pinned version, on a
- copy of `frontend/`).
-2. **Secondary gap (image would be broken even if it built):** stage 1
- bundles only `app.js` + `sources.js` and copies only `index.html` +
- `sources.html`. Missing from the image: `document.html` +
- `login.html` (phases 10/16), `document.js` + `login.js`, and
- `markdown.js` (classic script loaded by `index.html` +
- `document.html`).
-3. **Verified fix:** with relative imports (`from "./header.js"`) all
- four page scripts bundle cleanly with esbuild 0.25.5.
-4. **Latent double-evaluation trap:** all four HTML pages also load
- ``;
- - `frontend/sources.html` (~line 125), `frontend/document.html`
- (~line 80), `frontend/login.html` (~line 67) — same tag.
- - Update the surrounding HTML comments that describe the
- header-before-page-script load order (e.g. index.html ~lines
- 115–119): the order is now guaranteed by the page script's own
- `import` (hoisted, evaluated before the page script body calls
- `initSharedHeader()`).
-4. **`Containerfile` stage 1** — cover the whole app (keep the pinned
- `esbuild@0.25.5` and the existing flags):
- ```dockerfile
- RUN mkdir -p /out/assets \
- && esbuild ./assets/app.js --bundle --minify --format=esm --target=es2022 --outfile=/out/assets/app.js \
- && esbuild ./assets/sources.js --bundle --minify --format=esm --target=es2022 --outfile=/out/assets/sources.js \
- && esbuild ./assets/document.js --bundle --minify --format=esm --target=es2022 --outfile=/out/assets/document.js \
- && esbuild ./assets/login.js --bundle --minify --format=esm --target=es2022 --outfile=/out/assets/login.js \
- && esbuild ./assets/markdown.js --minify --outfile=/out/assets/markdown.js \
- && esbuild ./assets/styles.css --minify --outfile=/out/assets/styles.css \
- && cp ./index.html ./sources.html ./document.html ./login.html /out/
- ```
- (`markdown.js` is a classic script — minify only, **no** `--bundle`;
- it exposes globals used by the pages.)
-5. **Verify locally (no podman):** with esbuild 0.25.5, all four module
- bundles + the markdown minify succeed on the real `frontend/` (not a
- copy — the copy was only for the diagnosis).
-6. **`podman build -f Containerfile .`** → green.
-7. **Image smoke test** (results + log excerpt into the report dir):
- - throwaway Postgres 17 + pgvector (`podman compose up -d db` and
- point the container at it, or a one-off container with the same
- env as `compose.yaml`);
- - run the built image (migrations run via the entrypoint);
- - `GET /`, `/sources.html`, `/document.html`, `/login.html` → 200;
- - `GET /assets/app.js` → 200, minified (single-line-ish), and
- contains the header code (e.g. the `clearChatStorage` function
- body); `GET /assets/markdown.js`, `/styles.css`, the other three
- page modules → 200;
- - No CDN rule: none of the four served pages contain an `http(s)://`
- `src`/`href` asset reference.
- - Teardown the throwaway containers when done.
-8. **Dev-server regression check** (the tag removal touches dev page
- load — confirm boot order still holds): `uv run uvicorn
- app.main:app --reload`, load all four pages, check the sign-out
- binding exists exactly once (DevTools: no duplicate listener — one
- `POST /api/logout` per click) and `initSharedHeader()` ran. (The
- isolated E2E regressions run in task 02.)
-
-## Testing & Quality
-- Steps 5–8 above; `uv run ruff check . && uv run pyright` clean
- (no Python changes, but keep the gate green).
-
-## Completion Criteria
-- [ ] The recorded build failure is fixed at the root cause (relative
- imports) — not masked by an alias/patch.
-- [ ] All four direct `header.js` tags removed + comments updated; the
- page scripts' `import "./header.js"` is the only header load.
-- [ ] Stage 1 produces: 4 HTML pages, 4 bundled modules, minified
- `markdown.js`, minified `styles.css`.
-- [ ] `podman build` green; image smoke all-200 + No CDN + single
- header evaluation; dev-server boot unchanged (step 8).
-- [ ] Log/screenshot evidence in `.agent/reports/23_containerfile_build/`.
diff --git a/.agent/phases/complete/23_containerfile_build/02_integration_test_commit.md b/.agent/phases/complete/23_containerfile_build/02_integration_test_commit.md
deleted file mode 100644
index 812a8a8..0000000
--- a/.agent/phases/complete/23_containerfile_build/02_integration_test_commit.md
+++ /dev/null
@@ -1,70 +0,0 @@
-# Task 02 — Integration coverage test, story file, validation, commit
-
-**Phase:** `23_containerfile_build` · **Source:** `TODO.md` L6
-
-## Objective
-Pin the stage-1 asset coverage so it can't silently rot again (a new
-page/script/asset without a matching Containerfile line fails CI), plus
-regressions, story file, final validation, and the single atomic commit.
-
-## Work
-1. `tests/integration/test_containerfile_assets.py` (new — **hermetic**:
- parses `Containerfile` + `frontend/` as text, no podman, no network).
- Tests:
- 1. `test_every_html_page_is_copied_into_stage1` — for each
- `frontend/*.html` in the repo, a stage-1 line copies it into
- `/out` (regex over the `cp` line; the set must be exactly the
- four current pages — a new page added to `frontend/` fails this).
- 2. `test_every_local_asset_reference_is_produced` — collect every
- local `src=`/`href=` under `assets/` or `/assets/` from the four
- HTML files; each basename must be produced by a stage-1 line
- (an `esbuild … --outfile=/out/assets/` or a `cp` of it).
- (This is what catches a missing `markdown.js`-style gap.)
- 3. `test_page_module_scripts_are_bundled` — the set of `type="module"`
- page scripts referenced by the HTML (basenames) equals the set of
- scripts esbuild bundles in stage 1 (`app.js`, `sources.js`,
- `document.js`, `login.js`).
- 4. `test_header_module_is_imported_not_directly_loaded` — no HTML
- file contains a `` alongside the existing `app.js` module script). `index.html` already loads `markdown.js` as a classic script (needed by `renderDocument`).
-4. `frontend/sources.html` — load `document-modal.js` as a module (it needs `document.js`'s `renderDocument`, so both `document.js` and `document-modal.js` must be module scripts; `markdown.js` classic script stays). The Sources page currently loads `sources.js` as a module; add the modal module script next to it.
-5. Verify the no-CDN integration test (`tests/integration/test_api.py::test_index_html_served_locally`) still passes — the new module scripts are same-origin `` (no leading slash!), and `` **before** the page's module script on every page (classic script → runs at parse time; the module scripts execute later). No other template changes — the walker + attribute pass is the single mechanism; do **not** add `data-brand` markers. The phase-33 `?v=` rewriting picks the new ref up automatically (`app/core/caching.py` matches any `src="…assets/…"`).
-3. `frontend/assets/app.js` — replace the "Brain of Reese" literals with `window.BOR_BRAND` reads: the `UI_STATE` labels (~L107: "… is thinking" / "… is answering"), `TYPING_LABEL` (~L112), the elapsed-hint aria-label (~L540). Pattern: `const brand = () => window.BOR_BRAND || "Brain of Reese";` + template strings. (Mid-turn staleness: a label set before the fetch lands keeps the old name for that turn — accepted, see the phase's locked decisions.)
-4. `frontend/assets/document.js` — the page titles (L147/152: `${doc.title} · Brain of Reese` / `"Document not found · …"`) → use the same `window.BOR_BRAND` read (document.js is a module — `window.BOR_BRAND` is set by then).
-5. `Containerfile` — add the esbuild line for the new file next to the others (L17–23 pattern, classic script like `markdown.js`): `esbuild ./assets/brand.js --minify --outfile=/out/assets/brand.js`.
-6. Verify the no-op property: with the default settings the rendered DOM text is byte-identical to pre-phase on all five pages (the replace is a no-op for the default name) — the existing suites' title/label assertions are the guard; if any assert a string this task moved onto `window.BOR_BRAND`, the default path must render the identical bytes.
-
-## Testing & Quality
-- No Python logic — the story E2E (task 03) is the gate; the existing suites (which assert the default "Brain of Reese" titles/labels against the shared conftest server) must stay green **unchanged**.
-- No CDN (rule 6): no new external tags. UI Structure Check (rule 5): no landmark/contrast change — the brand text keeps its existing classes and styling (the `innerHTML` rewrite only re-emits the same structure with the new name).
-
-## Completion Criteria
-- [ ] `BOR_APP_NAME` unset → all five pages render exactly as today (existing suites green).
-- [ ] `BOR_APP_NAME="Brain of Testy"` → title/header/greeting/labels/aria all carry the new name (asserted by the story E2E, task 03).
-- [ ] The Containerfile build includes brand.js; the asset ref is versioned like its siblings (phase 33).
-- [ ] `uv run pytest` green; `uv run ruff check . && uv run pyright` clean.
diff --git a/.agent/phases/complete/39_configurable_brand/03_e2e_docs_commit.md b/.agent/phases/complete/39_configurable_brand/03_e2e_docs_commit.md
deleted file mode 100644
index 25f7e3e..0000000
--- a/.agent/phases/complete/39_configurable_brand/03_e2e_docs_commit.md
+++ /dev/null
@@ -1,27 +0,0 @@
-# Task 03 — Story E2E + docs + commit
-
-**Phase:** `39_configurable_brand` · **Source:** `TODO.md:12 — "Also need a way to customize the name for 'Brain of'. Should be an env var."`
-**Story:** `.agent/user_stories/configurable-brand.md`
-
-## Objective
-The story's isolated Playwright suite against an app instance booted with the overridden name, the env docs, and the phase commit.
-
-## Work
-1. `tests/e2e/test_configurable_brand.py` (the story gate — one story, one file, run in isolation):
- - A **second app instance** — the shared `app_server` conftest fixture keeps the default name (the other suites' title/label assertions depend on it). Copy the conftest `app_server` env block (same DB, the mock-LLM base URL, `BOR_ADMIN_PASSWORD`/`BOR_SESSION_SECRET`, `BOR_STATIC_DIR`, `BOR_RELEVANCE_THRESHOLD`) with two changes: `BOR_APP_NAME="Brain of Testy"` and a distinct port (`APP_PORT + 1` per the conftest convention). A session-scoped fixture **inside the test file**, started after `mock_llm` is available.
- - Assertions (custom instance): index `document.title` == `"Brain of Testy"`; the `.brand-text` `innerHTML` == `Brain of Testy`; the empty-state h1 text == `"Hey! I'm Brain of Testy."`; the `#messages` `aria-label` == `"Conversation with Brain of Testy"`; the sources page title `"Sources · Brain of Testy"`; the login page title `"Sign in · Brain of Testy"`; one chat turn with a pre-token window (the `think out loud` marker) → the button label shows `"Brain of Testy is thinking"`.
- - Default-name assertion (cheap regression in the same file): the shared conftest server's index title still == `"Brain of Reese"`.
- - The chat-turn assertion works on the default (possibly empty) KB — a deflected answer is fine; the label assertion is pre-token, so no DB seeding is required.
-2. `.env.example` — document `BOR_APP_NAME` in the App section (the display name on all pages; default "Brain of Reese").
-3. README — the configuration section: `BOR_APP_NAME` (what it affects: titles, the header brand, the status labels, the aria text; the default; the bold-split rendering rule: names starting "Brain of " bold the remainder, any other name renders in normal weight).
-4. Regression pass: `uv run pytest` + the coverage gate (>90%) + the isolated story E2E + the suites that assert brand strings (`test_smoke.py`, `test_shared_header.py`, `test_header_consistency.py`, `test_chat_persistence.py`) green.
-5. Commit — one atomic `--no-gpg-sign` Conventional Commits commit for the whole phase (AGENTS.md rule 8), e.g. `feat(brand): configurable app name — BOR_APP_NAME drives /api/config + the frontend brand layer`; move the phase directory to `.agent/phases/complete/`.
-
-## Testing & Quality
-- The gates above are this task's quality bar (A16: one story, one isolated E2E file, coverage >90%).
-
-## Completion Criteria
-- [ ] The story E2E is green in isolation, deterministic across two consecutive runs (custom-name instance + the default-name assertion).
-- [ ] The step-4 regression list green; coverage >90%.
-- [ ] `uv run ruff check . && uv run pyright` clean.
-- [ ] One `--no-gpg-sign` commit; phase directory moved to `.agent/phases/complete/`.
diff --git a/.agent/phases/complete/40_tuning_toggle_flash/01_toggle_ships_hidden.md b/.agent/phases/complete/40_tuning_toggle_flash/01_toggle_ships_hidden.md
deleted file mode 100644
index 2b1fdc7..0000000
--- a/.agent/phases/complete/40_tuning_toggle_flash/01_toggle_ships_hidden.md
+++ /dev/null
@@ -1,26 +0,0 @@
-# Task 01 — Toggle ships hidden, admin-only reveal
-
-**Phase:** `40_tuning_toggle_flash` · **Source:** `TODO.md:3` — "Loading the page briefly shows the 'Tuning' button in the header even when the user isn't authenticated. Only show that if the user is authenticated."
-**Story:** `.agent/user_stories/tuning-toggle-flash.md`
-
-## Objective
-Ship `#steering-toggle` `hidden` in all six pages and unhide it in `header.js` only when whoami says admin — zero flash for anonymous, identical admin UX.
-
-## Work
-1. `frontend/index.html`, `frontend/sources.html`, `frontend/document.html`, `frontend/git-sources.html`, `frontend/login.html`, `frontend/tuning.html` — add the `hidden` attribute to the existing `#steering-toggle` `