diff --git a/.agents/PLAN.md b/.agents/PLAN.md
new file mode 100644
index 0000000..f4ddb51
--- /dev/null
+++ b/.agents/PLAN.md
@@ -0,0 +1,112 @@
+# PLAN — Developer Homepage
+
+**Status:** live static site · **Last reviewed:** 2026-09-18 (security & code-quality audit)
+
+## 1. Project Identity
+
+A single-page personal portfolio (Reese Wells — self-hosting & infrastructure)
+with an interactive fake terminal (easter-egg commands, vim simulator,
+achievement system). No backend, no user accounts, no PII beyond public
+contact details and GPG public keys.
+
+## 2. Assumptions & Design Principles
+
+1. **Static first.** The site is plain HTML/CSS/JS. No runtime dependencies,
+ no CDNs, no build-time JS frameworks — the supply chain is the repo itself.
+2. **Boring deployment.** `build.sh` → hashed `dist/` → `nginx:alpine` on
+ `:8080` (plain HTTP) → TLS terminated by the external Caddy reverse proxy.
+3. **Test the artifact.** E2E tests run against the built `dist/`, not `src/`,
+ so what is tested is exactly what is deployed.
+4. **Security headers are a container property.** All hardening headers are
+ emitted by nginx (the only thing that serves the site in production).
+5. **Fun is safe.** The fake terminal's easter eggs (page-wipe, login screen,
+ vim) are DOM-only pranks — no real network or system effects. The only
+ real network behavior (curl/wget `fetch`) is being removed — see Phase 02.
+
+## 3. Architectural Anchors (LOCKED DECISIONS)
+
+| COMPONENT | DECISION | RATIONALE | STATUS |
+|---|---|---|---|
+| Frontend runtime | Vanilla HTML/CSS/JS, flat `src/*.js` files, `defer` load order | No toolchain, auditable, works with `build.sh` hashing | LOCKED (pre-existing) |
+| Terminal modules | `terminal-commands.js` (canned data) · `terminal-achievements.js` · `terminal-vim.js` · `terminal.js` (core) | Flat modules keep `build.sh`'s root-level hashing working; single source of truth for command data | LOCKED (2026-09-18 refactor) |
+| Build | `build.sh`: md5 content-hash cache-busting + `sed` reference rewrites → `dist/` | md5 is a cache key, not a security hash — acceptable | LOCKED (pre-existing) |
+| Serving | `nginx:alpine`, `listen 8080`, plain HTTP | TLS is added by the Caddy edge in front of the container | LOCKED (pre-existing) |
+| Edge | Caddy reverse proxy (Route53 DNS-validated TLS) in front of the container | HSTS/TLS are edge responsibilities | LOCKED (assumed — verify per Phase 03) |
+| E2E tests | `@playwright/test` pinned **1.62.0** (matches cached chromium-1234), serves `dist/` via `python3 -m http.server 8123`, config `playwright.config.js` | Tests the deployed artifact; pinned to avoid browser re-downloads | LOCKED (2026-09-18, user-approved) |
+| Deployment | Docker image → `gitea.reeseapps.com/services/homepage` via Gitea Actions (secrets-based registry login) | Self-hosted CI | LOCKED (pre-existing) |
+| Persistence | `localStorage` only (achievements, key `reese-terminal-achievements`) | No backend by design | LOCKED (pre-existing) |
+
+## 4. High-Level Architecture
+
+```
+src/ (index.html, style.css, script.js, terminal*.js, assets)
+ │ build.sh (hash + rewrite)
+ ▼
+dist/ (index.html + hashed assets) ◄── playwright webServer (port 8123)
+ │ Dockerfile (COPY → nginx html root) ◄── tests/e2e/*.spec.js (44 tests)
+ ▼
+nginx:alpine :8080 (HTTP, security headers, cache policy)
+ ▲
+Caddy edge (TLS, HSTS) ← reeseapps.com
+```
+
+- `src/terminal.js` — terminal core: prompt loop, history, tab completion,
+ dispatch, expand/collapse (`expandTerminal`/`collapseTerminal`, shared with
+ `script.js` hero click).
+- `src/terminal-commands.js` — `TERMINAL_COMMANDS` (canned outputs; values may
+ be functions evaluated at dispatch), `TERMINAL_COMMAND_LIST` (tab
+ completion), `getCommandOutput()`.
+- `src/terminal-vim.js` — `launchVimSimulator({content, terminal,
+ mobileInput, filename, setVimMode})`.
+- `src/terminal-achievements.js` — `ACHIEVEMENTS`, localStorage persistence,
+ toasts, hidden-section reveal, `initTerminalAchievements()`.
+- `src/script.js` — nav menu, IntersectionObserver fades, server-rack
+ background generation, hero-click terminal toggle.
+
+## 5. Validation / Verification Workflow
+
+1. `./build.sh` — rebuild `dist/`.
+2. `npm test` (Playwright) — 44 E2E tests against `dist/` on port 8123.
+ Every phase's final pass must leave the suite green.
+3. `.agents/validate.sh` (installed by the `phased-execution` skill) —
+ must run 1 + 2 and, for Phase 03, the header checks below.
+4. Container header check (Phase 03): `scripts/check-headers.sh` — builds the
+ image, runs it on a scratch port, asserts the four security headers on
+ `/`, `/index.html`, and a hashed asset.
+
+## 6. Data Model
+
+No server-side data. Client-side state:
+
+| Key | Where | Shape |
+|---|---|---|
+| `reese-terminal-achievements` | localStorage | JSON array of achievement ids (e.g. `["time_flies","nice_try"]`) |
+
+Session-only state (no persistence): terminal history, `isRoot`, vim state,
+menu open/closed, terminal grown/collapsed.
+
+## 7. Roadmap
+
+| Phase | Title | Source |
+|---|---|---|
+| `01_fix_history_xss` | Fix terminal history-recall XSS (M-1) | audit finding M-1 |
+| `02_simulate_fetch_commands` | Remove unvalidated client fetch from curl/wget (M-2) | audit finding M-2 |
+| `03_nginx_security_headers` | Security headers on every location (M-4) + `server_tokens off` (L-2) | audit findings M-4, L-2 |
+
+**Dismissed:** audit finding M-3 (README `0.0.0.0:8080` binding) — false
+positive per project owner (2026-09-18); the host firewall already restricts
+access and the site is fronted by the Caddy edge.
+
+**Completed in-session (not phased):** code-quality pass 2026-09-18 — module
+split (Q-1), dead-code removal (Q-2/3/4, `scrollOffset`), expand/collapse
+helpers (Q-5), rack-decoration CSS classes (Q-7), `hidden`-attribute reveal
+(Q-8), a11y label on terminal input (Q-9), dynamic `date` (Q-10), vim `dd`
+line-deletion fix, Playwright regression suite (44 tests). See
+`.agents/remediation_plan.md`.
+
+Remaining low-priority audit items (not yet phased): L-1 (verify HSTS at the
+Caddy edge — folded into Phase 03 verification), L-3 (drop
+`style-src 'unsafe-inline'` after moving inline `style=""` attrs to classes),
+L-5 (label the displayed SSH key as fictitious or show fingerprint only),
+L-7 (pin CI action SHAs / image signing), L-8 (build.sh verification test in
+CI).
diff --git a/.agents/phases/complete/.gitkeep b/.agents/phases/complete/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/.agents/phases/todo/01_fix_history_xss/00_phase.md b/.agents/phases/todo/01_fix_history_xss/00_phase.md
new file mode 100644
index 0000000..e3816b1
--- /dev/null
+++ b/.agents/phases/todo/01_fix_history_xss/00_phase.md
@@ -0,0 +1,38 @@
+# Phase 01 — Fix terminal history-recall XSS (audit M-1)
+
+**Story:** n/a
+**Context:** `.agents/PLAN.md` §4 (terminal modules), `.agents/remediation_plan.md` finding M-1
+
+## Objective
+Eliminate the DOM-XSS sink in the fake terminal's command-history recall:
+ArrowUp/ArrowDown currently rebuild the prompt line with
+`lastLine.innerHTML = '$ ' + cmdLine + ' '`
+where `cmdLine` is raw user input from `commandHistory`. Replace it with
+textContent-based construction so terminal input can never be injected as
+HTML, and lock the fix with a Playwright regression test.
+
+## Dependencies
+— (none)
+
+## Tasks
+1. `01_fix_history_recall.md` — Rewrite the ArrowUp/ArrowDown recall to build the prompt line with `createElement`/`textContent` (mirror the existing `updateDisplay()` pattern).
+2. `02_xss_regression_test.md` — Add a Playwright test proving a markup payload typed into the terminal is recalled as literal text, not DOM.
+
+## Testing & Quality
+- Unit/integration: the new regression test must cover (a) a payload that
+ would create a DOM node if injected (`x`), (b) a
+ payload with an inline event handler (`
90%** on new/modified code (the two recall branches in
+ `src/terminal.js`).
+- E2E: full Playwright suite (`npm test`) green — it already exercises
+ history recall with benign commands and must not regress.
+
+## Completion Criteria
+- [ ] `grep -n "innerHTML" src/terminal.js` shows no occurrence combining
+ `innerHTML` with `cmdLine`/history data (static prompt literals and the
+ neofetch canned-output assignment are the only allowed `innerHTML` uses)
+- [ ] new XSS regression test present in `tests/e2e/02-terminal.spec.js` and passing
+- [ ] `./build.sh && npm test` fully green (44+ tests)
+- [ ] no behavior change in completed work (terminal, vim, achievements, menu)
diff --git a/.agents/phases/todo/01_fix_history_xss/01_fix_history_recall.md b/.agents/phases/todo/01_fix_history_xss/01_fix_history_recall.md
new file mode 100644
index 0000000..0aeb8bc
--- /dev/null
+++ b/.agents/phases/todo/01_fix_history_xss/01_fix_history_recall.md
@@ -0,0 +1,49 @@
+# Task 01 — Rewrite history recall with the safe display path
+
+**Phase:** `01_fix_history_xss` · **Story:** n/a
+
+## Objective
+Remove the `innerHTML` sink from the terminal's ArrowUp/ArrowDown history
+recall by routing recall through the existing `updateDisplay(text)` function,
+which already builds the prompt line with `textContent`/`createElement`.
+
+## Work
+1. `src/terminal.js` — in the terminal `keydown` handler, replace the three
+ `lastLine.innerHTML = '$ ' + cmdLine + ' '`
+ constructions with `updateDisplay(...)` calls:
+
+ - **ArrowUp branch** (`if (historyIndex < commandHistory.length - 1)`):
+ delete the `innerHTML` line, the `newCursor` creation, and the
+ `appendChild` — keep `historyIndex++` and `const cmdLine = ...`, then:
+ ```js
+ updateDisplay(cmdLine);
+ ```
+ - **ArrowDown branch, first case** (`if (historyIndex > 0)`): same
+ replacement — `updateDisplay(cmdLine);`
+ - **ArrowDown branch, else case** (`historyIndex = -1`): replace the
+ `innerHTML`/cursor block with:
+ ```js
+ updateDisplay('');
+ ```
+
+ `updateDisplay` already: removes any stale `.terminal-cursor`, clears the
+ line via `textContent`, appends a `.terminal-prompt` span (respecting
+ `isRoot` → `#` vs `$`), the text, and a fresh cursor (red when root).
+
+ Note the two intentional, behavior-preserving improvements this brings:
+ (a) recalled prompts match the live typing state (cursor directly after
+ the text, no trailing space); (b) after `sudo su -`, recall shows `#`
+ instead of a stale `$`. Both are consistency fixes, not regressions.
+
+## Testing & Quality
+- Unit/integration: no new unit code — the existing E2E test
+ `02-terminal.spec.js › history: ArrowUp/ArrowDown recall previous commands`
+ exercises recall and execution of recalled commands and must stay green.
+- Coverage: **>90%** on the modified branches (covered by that E2E test plus
+ the task-02 regression test).
+
+## Completion Criteria
+- [ ] `grep -n "innerHTML" src/terminal.js` — no remaining occurrence that
+ involves `cmdLine` or history data
+- [ ] `./build.sh && npm test` fully green
+- [ ] no behavior change in completed work (terminal, vim, achievements, menu)
diff --git a/.agents/phases/todo/01_fix_history_xss/02_xss_regression_test.md b/.agents/phases/todo/01_fix_history_xss/02_xss_regression_test.md
new file mode 100644
index 0000000..c81a9a6
--- /dev/null
+++ b/.agents/phases/todo/01_fix_history_xss/02_xss_regression_test.md
@@ -0,0 +1,65 @@
+# Task 02 — Add the XSS regression test
+
+**Phase:** `01_fix_history_xss` · **Story:** n/a
+
+## Objective
+Add a Playwright E2E test that proves a markup payload typed into the
+terminal is recalled (ArrowUp) as **literal text**, never as DOM, and never
+executes. This test fails on the pre-fix (innerHTML) code and passes on the
+fixed code, so it guards against regressions/reverts.
+
+## Work
+1. `tests/e2e/02-terminal.spec.js` — add the following test inside the
+ `test.describe('Fake terminal — state and special commands', ...)` block
+ (it reuses `openTerminal`, `typeCommand`, `currentPromptLine` from
+ `./helpers`):
+
+ ```js
+ test('history recall renders markup payloads as literal text (no DOM injection)', async ({ page }) => {
+ await openTerminal(page);
+
+ // 1) A payload that would create a DOM node if innerHTML-injected.
+ await typeCommand(page, 'pwned');
+ await page.keyboard.press('ArrowUp');
+ await expect(page.locator('#xss-marker')).toHaveCount(0);
+ await expect(currentPromptLine(page)).toContainText('pwned');
+
+ // 2) An event-handler payload must not execute. `data:,` is an empty
+ // data URI so the
fails to decode deterministically (no
+ // network dependency) and onerror would fire if injected as HTML.
+ await page.evaluate(() => { window.__xssFired = false; });
+ await typeCommand(page, '
');
+ await page.keyboard.press('ArrowUp');
+ await page.waitForTimeout(200); // allow onerror to fire if vulnerable
+ expect(await page.evaluate(() => window.__xssFired)).toBe(false);
+ await expect(page.locator('img[onerror]')).toHaveCount(0);
+ await expect(currentPromptLine(page)).toContainText('
');
+
+ // 3) Benign recall still works and is executable.
+ await page.keyboard.press('ArrowDown');
+ await page.keyboard.press('ArrowDown');
+ await page.keyboard.press('Enter');
+ await expect(page.locator('.terminal-content > div').nth(-2)).toHaveText('optional');
+ });
+ ```
+
+ Notes for the executor:
+ - Step 3 walks back down through history (`
`, then ``, then the
+ prior real command) and executes it; adjust the final assertion to the
+ benign command that is actually on top of the history at that point if
+ the exact stack differs when you run it. The point is that recall +
+ execute of a benign command still works.
+ - Do **not** add an `onerror`-based assertion that depends on a real
+ network fetch; keep it self-contained with `data:,`.
+
+## Testing & Quality
+- Unit/integration: this test **is** the regression coverage for the M-1 fix.
+ It must: fail on innerHTML-based recall, pass on textContent-based recall.
+- Coverage: **>90%** on the modified recall branches (both payload classes +
+ benign path covered).
+
+## Completion Criteria
+- [ ] new test present in `tests/e2e/02-terminal.spec.js`
+- [ ] `./build.sh && npm test` fully green (45+ tests)
+- [ ] (sanity) temporarily reverting task 01's fix makes this test fail —
+ confirms it is a genuine regression guard, then re-apply the fix
diff --git a/.agents/phases/todo/02_simulate_fetch_commands/00_phase.md b/.agents/phases/todo/02_simulate_fetch_commands/00_phase.md
new file mode 100644
index 0000000..d043c48
--- /dev/null
+++ b/.agents/phases/todo/02_simulate_fetch_commands/00_phase.md
@@ -0,0 +1,44 @@
+# Phase 02 — Remove unvalidated client fetch from curl/wget (audit M-2)
+
+**Story:** n/a
+**Context:** `.agents/PLAN.md` §2 (principle 5 — fun is safe) & §3 (terminal
+modules), `.agents/remediation_plan.md` finding M-2
+
+## Objective
+The fake terminal's `curl`/`wget` commands run a real, unvalidated
+`fetch(url)` from the visitor's browser — a client-side request-forgery
+primitive (and cookie-attached once the site ever has auth) that is also
+silently broken in production (CSP `connect-src` falls back to
+`default-src 'self'`, blocking every external fetch). Replace the real
+fetch with **simulated output** consistent with the rest of the fake
+terminal: zero network I/O, deterministic, echoes the requested URL.
+
+**Chosen approach** (audit recommendation, owner-approved 2026-09-18):
+simulate. The rejected alternative (real fetch + https-only allowlist +
+`connect-src` + `credentials: 'omit'`) is documented for the record but out
+of scope.
+
+## Dependencies
+— (none)
+
+## Tasks
+1. `01_simulate_fetch_output.md` — Add `simulatedCurlOutput(url)` / `simulatedWgetOutput(url)` to `terminal-commands.js`; replace both `fetch()` calls in `terminal.js` with the simulated outputs.
+2. `02_fetch_regression_test.md` — Add Playwright tests proving curl/wget render simulated output, make **zero** non-origin network requests, and still unlock the `web_navigator` achievement.
+
+## Testing & Quality
+- Unit/integration: the regression tests must cover (a) `curl `
+ renders the simulated block and echoes the URL, (b) `wget ` same,
+ (c) no request to any non-origin host occurs (request listener),
+ (d) `web_navigator` achievement/toast still fires.
+- Coverage: **>90%** on new/modified code (the two simulator functions +
+ both branches).
+- E2E: full Playwright suite green.
+
+## Completion Criteria
+- [ ] `grep -n "fetch(" src/terminal.js` returns no matches (the only
+ `fetch(` in `src/` must be gone)
+- [ ] `simulatedCurlOutput` / `simulatedWgetOutput` present in
+ `src/terminal-commands.js`
+- [ ] new regression tests present in `tests/e2e/` and passing
+- [ ] `./build.sh && npm test` fully green (46+ tests)
+- [ ] no behavior change in completed work (terminal, vim, achievements, menu)
diff --git a/.agents/phases/todo/02_simulate_fetch_commands/01_simulate_fetch_output.md b/.agents/phases/todo/02_simulate_fetch_commands/01_simulate_fetch_output.md
new file mode 100644
index 0000000..3bdc1cd
--- /dev/null
+++ b/.agents/phases/todo/02_simulate_fetch_commands/01_simulate_fetch_output.md
@@ -0,0 +1,81 @@
+# Task 01 — Replace real fetch with simulated output
+
+**Phase:** `02_simulate_fetch_commands` · **Story:** n/a
+
+## Objective
+Remove the only real network I/O from the fake terminal: the `curl` and
+`wget` commands stop calling `fetch(url)` and instead render a
+deterministic, simulated response that echoes the requested URL. This kills
+the client-side request-forgery primitive (audit M-2) and makes the feature
+work identically in production (where CSP blocked the real fetch anyway).
+
+## Work
+1. `src/terminal-commands.js` — append two pure functions (no DOM, no
+ network). Keep them deterministic (the wget timestamp is the only
+ intentional non-determinism):
+
+ ```js
+ // Simulated fetch output. The fake terminal performs NO real network
+ // I/O by design — these produce a deterministic, clearly-simulated
+ // response that echoes the requested URL.
+ function _simulatedBody(url) {
+ return 'simulated body for ' + url;
+ }
+
+ function simulatedCurlOutput(url) {
+ const body = _simulatedBody(url);
+ return [
+ ' % Total % Received % Xferd Average Speed Time Time Time Current',
+ ' Dload Upload Total Spent Left Speed',
+ '100 ' + body.length + ' 100 ' + body.length + ' 0 0 ' + (body.length * 40) + ' 0 --:--:-- --:--:-- --:--:--',
+ body,
+ ].join('\n');
+ }
+
+ function simulatedWgetOutput(url) {
+ const body = _simulatedBody(url);
+ return [
+ '-- ' + new Date().toISOString().replace('T', ' ').slice(0, 19) + '-- ' + url,
+ 'Resolving host... (simulated)',
+ 'Connecting... connected (simulated).',
+ 'HTTP request sent, awaiting response... 200 OK',
+ 'Length: ' + body.length + ' [text/plain]',
+ "Saving to: 'index.html'",
+ '',
+ body,
+ '',
+ 'Finished (simulated). No real network request was made.',
+ ].join('\n');
+ }
+ ```
+
+2. `src/terminal.js` — in the `curl ` branch, delete the `fetch(url).then(...)
+ .catch(...)` chain and replace it with:
+ ```js
+ // Simulated fetch — the fake terminal never performs real network I/O.
+ setTimeout(() => {
+ outLine.textContent = simulatedCurlOutput(url);
+ content.scrollTop = content.scrollHeight;
+ }, 400);
+ ```
+3. `src/terminal.js` — in the `wget ` branch, make the identical change but
+ call `simulatedWgetOutput(url)`.
+
+ Keep the existing `url` extraction regexes, the `Downloading...` line, and
+ the `whiteSpace`/`color` styling. The `web_navigator` achievement (keyed
+ on the `curl `/`wget ` prefix) is untouched and still fires.
+
+## Testing & Quality
+- Unit/integration: no unit test needed for the pure output functions beyond
+ the E2E assertions in task 02 (they are string builders exercised through
+ the terminal). If desired, a tiny `node -e` sanity check:
+ `node -e "eval(require('fs').readFileSync('src/terminal-commands.js','utf8')); console.log(simulatedCurlOutput('https://x'))"`.
+- Coverage: **>90%** on the two new functions and both modified branches
+ (both covered by the task-02 E2E test).
+
+## Completion Criteria
+- [ ] `grep -n "fetch(" src/terminal.js` returns **no** matches
+- [ ] `simulatedCurlOutput` and `simulatedWgetOutput` defined in
+ `src/terminal-commands.js`
+- [ ] `./build.sh && npm test` fully green (existing 44 tests unaffected)
+- [ ] no behavior change in completed work (terminal, vim, achievements, menu)
diff --git a/.agents/phases/todo/02_simulate_fetch_commands/02_fetch_regression_test.md b/.agents/phases/todo/02_simulate_fetch_commands/02_fetch_regression_test.md
new file mode 100644
index 0000000..964e4b2
--- /dev/null
+++ b/.agents/phases/todo/02_simulate_fetch_commands/02_fetch_regression_test.md
@@ -0,0 +1,59 @@
+# Task 02 — Add the simulated-fetch regression test
+
+**Phase:** `02_simulate_fetch_commands` · **Story:** n/a
+
+## Objective
+Add a Playwright E2E test proving `curl`/`wget` render the simulated output,
+make **zero** non-origin network requests, and still unlock the
+`web_navigator` achievement.
+
+## Work
+1. `tests/e2e/02-terminal.spec.js` — add inside the
+ `test.describe('Fake terminal — state and special commands', ...)` block:
+
+ ```js
+ test('curl/wget are simulated — no real network request leaves the origin', async ({ page }) => {
+ await openTerminal(page);
+
+ // Track every request that is NOT aimed at the test origin.
+ const external = [];
+ page.on('request', (r) => {
+ if (!r.url().startsWith('http://127.0.0.1:8123')) external.push(r.url());
+ });
+
+ await typeCommand(page, 'curl https://example.com/hello');
+ // achievement fires on the first curl/wget
+ await expect(page.locator('.toast').filter({ hasText: 'Web Navigator' })).toBeVisible();
+ const curlLine = page.locator('.terminal-content > div').nth(-2);
+ await expect(curlLine).toContainText('simulated body for https://example.com/hello');
+
+ await typeCommand(page, 'wget https://example.org/file.txt');
+ const wgetLine = page.locator('.terminal-content > div').nth(-2);
+ await expect(wgetLine).toContainText('No real network request was made');
+ await expect(wgetLine).toContainText('https://example.org/file.txt');
+
+ expect(external).toEqual([]);
+ });
+ ```
+
+ Notes for the executor:
+ - The origin prefix `http://127.0.0.1:8123` must match `baseURL` in
+ `playwright.config.js`; keep them in sync if the port ever changes.
+ - The `web_navigator` toast is asserted right after the curl command
+ (toasts auto-remove after ~4.3 s).
+ - If `expect(external).toEqual([])` is flaky on your network (e.g. DNS
+ prefetch noise), tighten it to assert that no request URL contains
+ `example.com` or `example.org` instead — the point is that the
+ simulated URLs were never contacted.
+
+## Testing & Quality
+- Unit/integration: this test is the regression coverage for the M-2 fix —
+ it fails on the pre-fix code (a real `fetch` to example.com/example.org
+ would appear in `external`) and passes on the simulated code.
+- Coverage: **>90%** on both modified branches (both exercised).
+
+## Completion Criteria
+- [ ] new test present in `tests/e2e/02-terminal.spec.js`
+- [ ] `./build.sh && npm test` fully green (45+ tests)
+- [ ] (sanity) temporarily reverting task 01's change makes this test fail —
+ confirms the request tracking works, then re-apply
diff --git a/.agents/phases/todo/03_nginx_security_headers/00_phase.md b/.agents/phases/todo/03_nginx_security_headers/00_phase.md
new file mode 100644
index 0000000..d67b434
--- /dev/null
+++ b/.agents/phases/todo/03_nginx_security_headers/00_phase.md
@@ -0,0 +1,48 @@
+# Phase 03 — Security headers on every location (audit M-4)
+
+**Story:** n/a
+**Context:** `.agents/PLAN.md` §3 (serving + edge), `.agents/remediation_plan.md`
+findings M-4 (headers) and L-2 (`server_tokens`, included as a one-liner in
+the same file)
+
+## Objective
+Fix the nginx `add_header` inheritance trap: the `location = /index.html`
+and the hashed-asset location each define their own cache `add_header`
+directives, so they **lose** the server-level security headers (CSP,
+X-Frame-Options, X-Content-Type-Options, Referrer-Policy). Extract the four
+security headers into a snippet and `include` it in **every** location, so
+every response class carries the full header set. Also set
+`server_tokens off;` (L-2) in the same pass.
+
+## Dependencies
+— (none)
+
+## Tasks
+1. `01_extract_header_snippet.md` — Create `nginx-security-headers.inc`, wire it into the Dockerfile, and `include` it in the server block and both header-defining locations (plus `server_tokens off;`).
+2. `02_add_header_verification.md` — Add `scripts/check-headers.sh` that builds and runs the real image and asserts all four headers on `/`, `/index.html`, and a hashed asset.
+
+## Testing & Quality
+- Unit/integration: the container-based header check (task 02) is the
+ integration test. It must assert all four headers on each of the three
+ response classes.
+- Coverage: **>90%** on new/modified config (all three location paths
+ exercised by the check script).
+- The Playwright E2E suite is unaffected (its webServer is plain python,
+ not nginx) and must stay green.
+
+## Completion Criteria
+- [ ] `nginx-security-headers.inc` exists and holds the four `add_header …
+ always;` lines
+- [ ] `nginx.conf` has **no** inline security `add_header` lines (only the
+ `include`), and both header-defining locations `include` the snippet
+- [ ] `server_tokens off;` present in the `server` block
+- [ ] `scripts/check-headers.sh` runs the real image and prints
+ `HEADER CHECK PASSED` (all four headers on `/`, `/index.html`, and a
+ hashed asset)
+- [ ] `./build.sh && npm test` fully green
+- [ ] no behavior change in completed work (terminal, vim, achievements, menu)
+
+**Out of scope (separate, unphased):** L-1 HSTS — that is an **edge/Caddy**
+responsibility (the container serves plain HTTP on 8080). Verify at the edge
+manually: `curl -sI https://reeseapps.com/ | grep -i strict-transport`. Do
+**not** add HSTS inside the 8080 container.
diff --git a/.agents/phases/todo/03_nginx_security_headers/01_extract_header_snippet.md b/.agents/phases/todo/03_nginx_security_headers/01_extract_header_snippet.md
new file mode 100644
index 0000000..f4edb59
--- /dev/null
+++ b/.agents/phases/todo/03_nginx_security_headers/01_extract_header_snippet.md
@@ -0,0 +1,96 @@
+# Task 01 — Extract header snippet and include it in every location
+
+**Phase:** `03_nginx_security_headers` · **Story:** n/a
+
+## Objective
+Move the four security headers into a shared snippet and `include` it in
+**every** location, so no location can silently drop them by defining its own
+`add_header` (the nginx inheritance trap, audit M-4). Add `server_tokens off;`
+(audit L-2) in the same pass.
+
+## Work
+1. Create `nginx-security-headers.inc` (repo root, next to `nginx.conf`) with
+ exactly:
+ ```
+ # Shared security headers — included by EVERY nginx location.
+ # A location that defines its own add_header inherits none from the
+ # server block, so each location includes this snippet directly.
+ add_header X-Frame-Options "SAMEORIGIN" always;
+ add_header X-Content-Type-Options "nosniff" always;
+ add_header Referrer-Policy "strict-origin-when-cross-origin" always;
+ add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self';" always;
+ ```
+
+2. `Dockerfile` — create the snippets dir and copy the file in. Change the
+ existing `RUN rm … && apk add …` line to also `mkdir -p /etc/nginx/snippets`,
+ and add a COPY right before the `COPY nginx.conf …` line:
+ ```dockerfile
+ RUN rm /etc/nginx/conf.d/default.conf && \
+ apk add --no-cache coreutils && \
+ mkdir -p /etc/nginx/snippets
+
+ COPY nginx-security-headers.inc /etc/nginx/snippets/security-headers.inc
+ COPY nginx.conf /etc/nginx/conf.d/default.conf
+ ```
+
+3. `nginx.conf` — rewrite to the exact content below. Key changes: the four
+ inline `add_header` lines are gone from the `server` block; `server_tokens
+ off;` is added; each of the three `location` blocks `include`s the snippet
+ (so no location relies on header inheritance):
+ ```nginx
+ server {
+ listen 8080;
+ server_name _;
+
+ root /usr/share/nginx/html;
+ index index.html;
+
+ # Hide the nginx version in the Server header (audit L-2)
+ server_tokens off;
+
+ # Gzip compression
+ gzip on;
+ gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript;
+ gzip_min_length 256;
+ gzip_vary on;
+
+ # Never cache index.html so the latest HTML is always served
+ location = /index.html {
+ include /etc/nginx/snippets/security-headers.inc;
+ add_header Cache-Control "no-cache, no-store, must-revalidate";
+ add_header Pragma "no-cache";
+ add_header Expires "0";
+ try_files $uri =404;
+ }
+
+ # Cache hashed assets forever (filename contains content hash)
+ location ~* \.(css|js|jpeg|jpg|png|gif|ico|svg|woff2?)$ {
+ include /etc/nginx/snippets/security-headers.inc;
+ add_header Cache-Control "public, max-age=31536000, immutable";
+ try_files $uri =404;
+ }
+
+ location / {
+ include /etc/nginx/snippets/security-headers.inc;
+ try_files $uri $uri/ /index.html;
+ }
+
+ # Custom error pages
+ error_page 404 /index.html;
+ }
+ ```
+
+## Testing & Quality
+- Unit/integration: config correctness is proven by the container check in
+ task 02 (headers present on all three response classes) and by `nginx -t`
+ (valid syntax, run implicitly by the container starting).
+- Coverage: **>90%** on the modified config (all three locations exercised).
+
+## Completion Criteria
+- [ ] `nginx-security-headers.inc` exists with the four `add_header … always;`
+ lines
+- [ ] `nginx.conf` contains **no** inline security `add_header` lines and has
+ `include /etc/nginx/snippets/security-headers.inc;` in all three
+ locations, plus `server_tokens off;`
+- [ ] `Dockerfile` copies the snippet into the image
+- [ ] `./build.sh && npm test` still fully green (Playwright is unaffected)
diff --git a/.agents/phases/todo/03_nginx_security_headers/02_add_header_verification.md b/.agents/phases/todo/03_nginx_security_headers/02_add_header_verification.md
new file mode 100644
index 0000000..fba346e
--- /dev/null
+++ b/.agents/phases/todo/03_nginx_security_headers/02_add_header_verification.md
@@ -0,0 +1,87 @@
+# Task 02 — Add container-level header verification
+
+**Phase:** `03_nginx_security_headers` · **Story:** n/a
+
+## Objective
+Add a repeatable, container-based verification that the **real** image serves
+all four security headers on every response class (`/`, `/index.html`, and a
+hashed asset). This is the integration test for the M-4 fix and guards against
+future nginx config regressions.
+
+## Work
+1. Create `scripts/check-headers.sh` (repo root; `mkdir -p scripts` first)
+ with exactly:
+ ```bash
+ #!/usr/bin/env bash
+ set -euo pipefail
+ # Verify every response class (/, /index.html, a hashed asset) carries the
+ # full security-header set, using the REAL nginx image on a scratch port.
+ # Requires a container runtime (podman or docker).
+
+ ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+ cd "$ROOT"
+
+ if command -v podman >/dev/null 2>&1; then RUNTIME=podman
+ elif command -v docker >/dev/null 2>&1; then RUNTIME=docker
+ else echo "ERROR: neither podman nor docker found" >&2; exit 2; fi
+
+ IMAGE="homepage-headercheck-$$"
+ PORT=18099
+ CID=""
+ trap 'if [ -n "$CID" ]; then $RUNTIME rm -f "$CID" >/dev/null 2>&1 || true; fi' EXIT
+
+ echo ">> building image ($RUNTIME)..."
+ $RUNTIME build -q -t "$IMAGE" .
+
+ echo ">> starting container on 127.0.0.1:$PORT..."
+ CID="$($RUNTIME run -d --rm -p 127.0.0.1:$PORT:8080 "$IMAGE")"
+
+ for i in $(seq 1 30); do
+ curl -sf -o /dev/null "http://127.0.0.1:$PORT/" && break
+ sleep 0.5
+ if [ "$i" -eq 30 ]; then echo "ERROR: container not ready" >&2; exit 1; fi
+ done
+
+ ASSET="$(curl -s "http://127.0.0.1:$PORT/" \
+ | grep -oE '(style|script|terminal)[a-z-]*\.[0-9a-f]{32}\.(css|js)' | head -n1 || true)"
+ if [ -z "$ASSET" ]; then echo "ERROR: no hashed asset found in index.html" >&2; exit 1; fi
+
+ fail=0
+ check_headers() {
+ local path="$1" hdrs h
+ hdrs="$(curl -sI "http://127.0.0.1:$PORT$path")"
+ for h in "X-Frame-Options" "X-Content-Type-Options" "Referrer-Policy" "Content-Security-Policy"; do
+ if ! printf '%s' "$hdrs" | grep -qi "^${h}:"; then
+ echo "FAIL: ${h} missing on ${path}" >&2; fail=1
+ fi
+ done
+ [ "$fail" -eq 0 ] && echo "OK: all security headers present on ${path}"
+ }
+
+ echo ">> checking headers..."
+ check_headers "/"
+ check_headers "/index.html"
+ check_headers "/${ASSET}"
+
+ if [ "$fail" -ne 0 ]; then echo "HEADER CHECK FAILED" >&2; exit 1; fi
+ echo "HEADER CHECK PASSED"
+ ```
+ Then `chmod +x scripts/check-headers.sh`.
+
+2. Run it: `./scripts/check-headers.sh` — it must print
+ `HEADER CHECK PASSED` with an `OK:` line for each of the three paths.
+
+## Testing & Quality
+- Unit/integration: this script **is** the integration test — it builds the
+ real image, serves it, and asserts all four headers on each response class.
+- Coverage: **>90%** on the modified nginx config (all three location paths
+ asserted).
+
+## Completion Criteria
+- [ ] `scripts/check-headers.sh` exists and is executable
+- [ ] `./scripts/check-headers.sh` exits 0 and prints `HEADER CHECK PASSED`
+ (with `OK:` lines for `/`, `/index.html`, and the hashed asset)
+- [ ] (negative check) temporarily removing one `include` from a location in
+ `nginx.conf` makes the script FAIL for that path — confirms the check
+ has teeth, then restore it
+- [ ] `./build.sh && npm test` fully green
diff --git a/.agents/remediation_plan.md b/.agents/remediation_plan.md
new file mode 100644
index 0000000..6add8ed
--- /dev/null
+++ b/.agents/remediation_plan.md
@@ -0,0 +1,238 @@
+# Security & Code Quality Audit — homepage
+
+**Date:** 2026-09-18
+**Auditor:** pi agent (read-only audit; no fixes applied at audit time)
+**Method:** manual source review (all files) + tooling: `git log`, `git grep` secret scan, `ssh-keygen` key validation, `curl` live-header probe.
+
+## Disposition (2026-09-18, same session as the audit)
+
+- **M-1, M-2, M-4 → scheduled as phases** under the phased-execution protocol:
+ `.agents/phases/todo/01_fix_history_xss/`, `02_simulate_fetch_commands/`,
+ `03_nginx_security_headers/` (see `.agents/PLAN.md` §7). Execute with the
+ `phased-execution` skill (`auto-phase.sh`).
+- **M-3 → DISMISSED (false positive per project owner, 2026-09-18).** The
+ host firewall already restricts access to the container port and the site
+ is fronted by the Caddy TLS edge; the README's `0.0.0.0:8080` binding is
+ accepted as-is.
+- **Code-quality findings (Q-1…Q-12, plus the vim `dd` dead path) → executed
+ directly in-session** (explicit owner instruction to run them outside the
+ phase pipeline): module split (`terminal.js` 1353 → 652 lines +
+ `terminal-commands.js` / `terminal-vim.js` / `terminal-achievements.js`),
+ dead-code removal, expand/collapse helpers, rack-decoration CSS classes,
+ `hidden`-attribute reveal, a11y label, dynamic `date`, vim line-deletion
+ fix. A 44-test Playwright E2E regression suite (`tests/e2e/`, run with
+ `./build.sh && npm test`) was created and is green.
+- Remaining Low items (L-1, L-3, L-5, L-7, L-8) stay unphased; L-1 is folded
+ into Phase 03's verification notes, L-2 into Phase 03 task 01.
+
+## Scope & Stack
+
+- **Stack:** static site (vanilla HTML/CSS/JS, no framework, no build deps) → hashed via `build.sh` (md5 cache-bust + `sed` rewrites) → served by `nginx:alpine` on `:8080` (plain HTTP, TLS expected at an external Caddy edge) → Docker/Podman image pushed to `gitea.reeseapps.com/services/homepage` via Gitea Actions.
+- **Core functionality:** personal portfolio with an interactive fake terminal (easter-egg commands, vim simulator, achievements in `localStorage`).
+- **Data sensitivity:** none at rest. Public PII only (name, email, GPG public keys). No auth, no backend, no forms.
+- **Assumptions:** TLS termination + HSTS happen at the Caddy reverse proxy in front of this container (container only listens on 8080 HTTP). Live site headers could not be verified from this network.
+
+**Overall:** low-risk attack surface (static, no input persistence, no backend). No Critical/High findings. One Medium XSS-pattern, two Medium deployment issues, several Low hardening items, and a number of code-quality debts (dead code, duplication, untested build).
+
+## Findings (severity-ranked)
+
+---
+
+### M-1. XSS sink: terminal command history recalled via `innerHTML`
+- **Severity:** Medium (High if ever served without the CSP below)
+- **Type:** CWE-79 (DOM-based XSS via innerHTML)
+- **Location:** `src/terminal.js`, ArrowUp/ArrowDown handlers in the terminal `keydown` listener (~lines 330–370)
+- **Description:** The Enter handler stores raw user input into `commandHistory` (captured via `textContent`, so typing is safe). But ArrowUp/ArrowDown recall it with:
+ ```js
+ lastLine.innerHTML = '$ ' + cmdLine + ' ';
+ ```
+ `cmdLine` is untrusted input injected into `innerHTML`.
+- **PoC:** In the site's terminal type:
+ ```
+
+ ```
+ press Enter, then press ↑. The payload is injected as HTML.
+ Mitigated *today* by the deployed CSP `script-src 'self'` (inline handlers and inline `