Files

297 lines
31 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# PLAN: Canadian Retirement Planner → https://retire.krisforbes.ca
**Status:** PLAN v1 — written 2026-10-08 ~21:30 EDT by Hermes (GLM). **Execution: DSH (local 27B), all stages, zero user interaction.**
**User authorization (verbatim from request):** "plan it then DSH will execute all stages, do this without my intervention for all stages as i will be away." → **Every stage below is pre-approved. Ask ZERO questions. Use the defaults and failure policy in this document.**
## 0. Goal
Build and deploy a **Canadian-focused retirement calculator** at **https://retire.krisforbes.ca** that exceeds https://www.wealthsimple.com/en-ca/tool/retirement-calculator with:
1. **Real Canadian mechanics**: CPP (start-age 60–70 with actuarial adjustment, income-based estimate, YMPE cap), OAS (65–70 deferral, 75+ boost, **clawback/recovery tax**), GIS (simplified), RRSP→**RRIF conversion at 71 with mandatory minimum withdrawals**, user-specified **"withdraw $X/yr from my RRSP"**, TFSA (tax-free), non-registered (50% capital-gains inclusion), pension/rental income, **federal + provincial 2025 tax brackets per province**, age amount, pension income splitting, BPA credits.
2. **"Next → next → next" questionnaire**: one question per screen, progress indicator, and a **persistent "Your plan so far" feedback panel below the question** that recomputes live after every answer and shows running stats + one contextual insight per question.
3. **Simple graphs**: hand-rolled inline SVG (no CDN dependency): (G1) portfolio balance by age, (G2) stacked retirement income by source at 65/70/75/80/85, (G3) annual tax + OAS clawback in retirement, (G4) lifetime income-source donut.
4. **NanoGPT image integration**: a small gallery of Canadian retirement-scene images generated with the user's nano-gpt account using the **cheapest allowance-included model**, wired into a "Your Retirement Vision" card on the results page (matched to province + interests, browsable gallery).
5. **Tax-smart drawdown engine**: solves the RRSP/RRIF withdrawal that *exactly* meets the spending goal after tax and OAS clawback (bisection), instead of naive flat withdrawals.
**Non-goals (do not build):** accounts/login, server-side storage (site is 100% client-side — answers never leave the browser; **say this on the site**), French UI, Monte Carlo simulation, DB pension modeling (user enters pension $ directly).
## 0.1 Execution model (DSH)
- Workspace (workdir, all local writes RELATIVE to it): `/home/ubuntu/projects/retire-planner`
- Launch (user pastes this, or Hermes fires it on the user's word "go"):
```
~/.local/bin/dsh-run /home/ubuntu/projects/retire-planner "Read PLAN.md in this workspace root FIRST — it is the complete spec. Execute ALL stages 0-10 end-to-end. The user is away and pre-approved every stage: ask ZERO questions, follow the defaults and failure policy. When done, REPORT.md must exist in the workspace root and your FINAL output line must be exactly: RETIRE_PLANNER_DONE"
```
- DSH constraints (from deepseek-harness skill): keep prompts free of real secrets (read keys from files at runtime); local file writes relative to the workspace; repo commits in the workspace; `dsh-run` positional arg = workdir. Long build (~30–45 min) is expected and fine.
- **Done criteria:** site live at https://retire.krisforbes.ca returning 200 with marker `<!-- retire-planner v1 -->`, REPORT.md written, final line `RETIRE_PLANNER_DONE`.
## 0.2 Ground truth gathered at plan time (do not re-derive; re-verify only what the stages say to re-verify)
| Fact | Value |
|---|---|
| CF zone id (krisforbes.ca) | `8cf8e4d7035b85deb6ebfe0026da3392` |
| CF API token (live) | **NOT in shell env.** Extract on Unraid: `grep -oE 'cfut_[A-Za-z0-9_\-]+' /mnt/cache/docker/caddy/config/caddy/autosave.json | head -1` (this is Caddy's LE DNS-challenge token; proven recipe in skill `mcp-deployment-pipeline` §"Cloudflare API token") |
| Existing DNS pattern | CNAME `<sub>.krisforbes.ca` → `dyn.krisforbes.ca`, `proxied:false`, ttl 1. DNS POST = JSON body to `/client/v4/zones/<zone>/dns_records` with `Authorization: Bearer <tok>` |
| Unraid | `root@192.168.0.123` ssh works from this box (BatchMode). **Unraid has NO python3.** Pipe scripts to `ssh root@192.168.0.123 bash -s` on stdin (quoting via `bash -c '<cmd>'` loses everything after the first quote) |
| Free host ports on Unraid | 8100 simcoe, 8101 blogapp, 8102 dashboard, 8105 firecrawl, 8106 novel, 8109 apex300 are TAKEN → **use 8103** (verified free at plan time; re-check in Stage 6) |
| Caddy | container `CaddyV2` (image `caddy:cloudflare`), Caddyfile at `/mnt/cache/docker/caddy/config/Caddyfile`. Existing per-subdomain block pattern (blogapp/dashboard):
```
# HERMES: <name>
<sub>.krisforbes.ca {
encode gzip
reverse_proxy 192.168.0.123:<port> {
header_up X-Real-IP {remote_host}
}
}
# END HERMES: <name>
```
Edit → **`docker restart CaddyV2`** (reload does NOT pick up bind-mount changes). TLS is handled globally (no tls block needed — grey-cloud DNS-only sites get LE certs via the caddy:cloudflare plugin). |
| Webroot convention | Skill `static-site-unraid-deploy`: appdata at `/mnt/better/appdata/<name>` (NOT /mnt/cache). blogapp is the known exception at `/mnt/cache/appdata/blogapp`. **Use `/mnt/better/appdata/retireapp/`** |
| nginx pattern | blogapp = nginx container, webroot bind-mounted `:ro` at `/usr/share/nginx/html`. Copy blogapp's exact image tag: `docker inspect blogapp --format '{{.Config.Image}}'` |
| Gitea | `https://gitea.krisforbes.ca` (API also at `http://192.168.0.123:3000/api/v1`, version 28.0.0). Credentials in `~/.git-credentials`: `https://krisf:<tok>@gitea.krisforbes.ca` (git uses it automatically; for API calls parse it, **never print the token**). Create repos via Gitea API, push via git HTTPS — never scp/ssh-copy repo content. |
| nano-gpt key | `HERMES_CUSTOM_NANO_GPT_API_KEY=` in `~/.hermes/.env` (read at runtime, never commit/print) |
| nano-gpt image API | `POST https://nano-gpt.com/api/v1/images/generations` (Image API only — the chat-completions image route is dead with `zdr_provider_required`). Catalog: `GET /api/v1/images/models`; per-model params: `GET /api/v1/images/models/<id>/endpoints`. Response: `data[].b64_json` **or** `data[].url` (R2 links expire — download immediately), plus `cost` and `remainingBalance`. |
| **Free/cheap image models (checked live 2026-10-08)** | No model is literally $0 list price. Cheapest allowance-included: **`z-image-turbo` — $0.0119/image** (Pro daily allowance; fastest ~5 s; pixel-grid resolutions like `1024*1024`, max 1536*1536, max 4 images/call, prompt ≤1200 chars) and **`qwen-image` — $0.02/image** (also allowance-included). **`nano-banana-pro-ultra` = $0.26 — FORBIDDEN by user.** Budget: 13 images × $0.0119 ≈ **$0.16 worst case**. |
| node on this box | `/home/ubuntu/.hermes/tools/node-26.7.0-linux-x64/bin/node` v26.7.0 (on PATH as `node`) — use it to unit-test the engine |
| Off-LAN verification vantage | Home hairpin NAT is BLOCKED — `curl https://<sub>.krisforbes.ca` from this box proves nothing about inbound. Verify from Midhurst: site proxy `projects/ego-test/site_proxy.py` at `synoadmin@100.96.20.61:8899` (use as `curl -x http://100.96.20.61:8899 ...`), or the troublebox `trouble@100.96.20.61:2222`. DNS-only (grey cloud) records mean traffic goes straight to the WAN IP (UDR7 DDNS via `dyn.krisforbes.ca`). |
| Existing `retire` DNS/Caddy | Not present at plan time (Caddyfile blocks: tv/bw/photos/midhurstroofing/gitea/plex/simcoe/blogapp/dashboard/bluetti/forces/novel; no retire). Stage 8 checks for an existing record first anyway. |
## 0.3 Failure policy (user is away — decide, don't ask)
- **Stage 4 (images) fails or balance < $1** → ship the site with built-in SVG gradient placeholder art; note it in REPORT.md. NOT fatal.
- **Stage 8 (CF DNS) fails** (no token / API error) → site stays live at `http://192.168.0.123:8103`; write a loud BLOCKER section in REPORT.md with the exact manual command. NOT fatal to the build.
- **Any other stage fails** → fix forward if a safe fix exists; otherwise roll back Stage 7 (Caddy) so no half-configured route remains, and write the failure + evidence to REPORT.md. Never leave the Caddyfile broken: always `docker restart CaddyV2` only after the block is syntactically complete, and keep the backup.
- Every stage appends evidence (command + output snippet) to REPORT.md as it goes, so a failure mid-run is diagnosable without re-running anything.
---
## Stage 0 — Preflight (read-only; ~2 min)
Run and log to REPORT.md:
1. `pwd` is the workspace; `node --version`; `git --version`.
2. Gitea reachable: `curl -s http://192.168.0.123:3000/api/v1/version`.
3. Unraid reachable: `ssh -o BatchMode=yes -o ConnectTimeout=10 root@192.168.0.123 'echo ok'`.
4. Port 8103 free: `ssh root@192.168.0.123 'ss -tlnp | grep :8103'` → expect empty.
5. nano-gpt key present: `grep -c '^HERMES_CUSTOM_NANO_GPT_API_KEY=' ~/.hermes/.env` → 1. Fetch balance/allowance later in Stage 4 (response carries `remainingBalance`).
6. Confirm no existing repo `retire-planner`: `curl -s http://192.168.0.123:3000/api/v1/repos/krisf/retire-planner` → expect 404.
## Stage 1 — Canadian constants module (`site/engine/canada.js`)
**Fetch-first, fallback-second.** Create `CONSTANTS` with every value stamped `{value, source, asOf}`. Fetch these pages (web fetch or curl; canada.ca is reachable from this box) and parse the current figures:
| Key | Source URL | Fallback (stamp "FALLBACK — verify") |
|---|---|---|
| `cppMax65Monthly` (max CPP at 65) | canada.ca/en/services/benefits/publicpensions/cpp/cpp-benefit/amount.html | 1433.00 (2025) |
| `ympe` (Year's Maximum Pensionable Earnings) | same page | 69700 (2025) |
| `oasMax6574Monthly` | canada.ca/en/services/benefits/publicpensions/cpp/old-age-security/payments.html | 713.34 (2025 Q1) |
| `oas75Boost` | same (10% at 75) | 1.10 |
| `oasClawbackThreshold` (net income) | canada.ca/en/services/benefits/publicpensions/cpp/old-age-security/recovery-tax.html | 93454 (2025) |
| `gisMaxSingleMonthly` (simplified) | canada.ca/.../guaranteed-income-supplement.html | 1086.88 (2025 Q3, verify) |
| RRIF minimum % by age 71–95 | canada.ca/en/revenue-agency/services/tax/individuals/topics/registered-retirement-income-fund-rrif (minimum withdrawal tables) | table: 71:5.28 72:5.40 73:5.53 74:5.67 75:5.82 76:5.98 77:6.17 78:6.36 79:6.58 80:6.82 81:7.08 82:7.38 83:7.71 84:8.08 85:8.51 86:8.99 87:9.55 88:10.21 89:10.99 90:11.91 91:13.06 92:14.49 93:16.34 94:18.79 95:20.00 |
| Federal 2025 brackets | canada.ca/en/revenue-agency/services/tax/individuals/frequently-asked-questions-individuals/canadian-income-tax-rates-individuals-current-previous-years.html | 15% ≤57,375; 20.5% ≤114,750; 26% ≤177,882; 29% ≤253,414; 33% above. BPA 15,705. Age amount max 8,790 (65+, claws back 15% of net income over 44,017). Pension income amount 2,000 |
| Provincial 2025 brackets + BPA, all 13 prov/terr | canada.ca provincial tax pages / taxtips.ca 2025 tables | embed at least ON/QC/BC/AB; if a province can't be fetched, use federal-only + a flat 5% surrogate **and stamp it clearly** |
| CPP actuarial factors | canada.ca CPP "before/after 65" adjustment | −0.6%/month before 65 (60 → ×0.64), +0.7%/month after 65 (70 → ×1.42) |
| OAS deferral | canada.ca OAS | +0.6%/month 65→70 |
Rules: all money in the engine is **real (today's) dollars** — inflation only indexes the *displayed* CPP/OAS amounts and is used for the inflation assumption insight. Defaults stamped in the UI: real return 3.5% pre-retirement / 2.5% post (net of fees), inflation 2%, life expectancy 95.
## Stage 2 — Calculator engine (`site/engine/engine.js`)
Pure functions, no DOM. UMD export (`if (typeof module !== 'undefined') module.exports = {...}`) so node can unit-test. Core:
```
inputs = {
age, province, household: 'single'|'couple',
partner: {age, income, rrsp, tfsa, nonreg, monthlyContribution} | null,
income, rrsp, tfsa, nonreg, monthlyContribution, employerMatchMonthly,
retireAge, spendingAnnual (today's $),
cppStartAge (60-70), cppOverrideMonthlyAt65 (0 = auto),
oasStartAge (65-70),
rrspWithdrawalOverrideAnnual (0 = auto),
pensionAnnual, rentalAnnual, otherAnnual,
realReturnPre (0.035), realReturnPost (0.025), inflation (0.02), lifeExp (95)
}
```
Functions:
1. `estimateCppMonthlyAt65(income, age)` → `0.25 × min(avgEarnings, ympe) × min(1, contribYears/40)` where `contribYears ≈ max(0, age − 22)` capped at 47; capped at `cppMax65Monthly`. (contributory period 18→65 = 47 yrs; general drop-out ≈ 17% → ~40 effective years. Document as an estimate; the UI shows the canada.ca link to get the real statement.)
2. `cppMonthlyAt(age, startAge, at65)` → `age < startAge ? 0 : at65 × adj(age)` with `adj = (1 − 0.006×monthsBefore65)` if startAge<65 else `(1 + 0.007×monthsAfter65)`; applied to the pension *started* at startAge: `monthly = at65 × adj(startAge)` and stays flat (real) after start.
3. `oasMonthlyAt(startAge)` → 0 before start; `oasMax6574 × (1 + 0.006×monthsAfter65 if startAge>65) × (1.10 if age≥75)`.
4. `tax(taxableIncome, province, age, {pensionIncome})` → federal + provincial on brackets; credits: BPA (fed+prov, clawed back 15% over ~$165k/$173k net income thresholds — simplify: reduce BPA by 15% of net income above 165,000, floor 0), age amount if 65+ (clawback 15% over 44,017), pension income amount $2,000 (fed+prov) on eligible pension/RRIF income if 65+. Couple mode: pension splitting — model 50% of pension+RRIF income split (recompute both partners' tax on split incomes, sum). Keep this simple but present.
5. `clawback(netIncome)` → `0.15 × max(0, netIncome − oasClawbackThreshold)`.
6. `gis(incomeExOas, household)` → simplified: only if income ex-OAS below ~$20k (single) / ~$26k (couple, both 65+); `gis = max(0, gisMax − 0.5 × max(0, incomeExOas − 5000))` (first $5k employment exemption approximated); non-taxable. Stamp "simplified".
7. `project(inputs)` → year-by-year array from `age` to `lifeExp`:
- Accumulation (age → retireAge): `bal = bal×(1+r) + 12×contrib(+match)`; contributions grow at `inflation` (real terms: keep flat — choose flat, document). Track `rrsp`, `tfsa`, `nonreg` separately. At retireAge: `nestEgg = sum`.
- Drawdown (retireAge → lifeExp), all real dollars:
- `cpp = cppMonthlyAt(...)×12`, `oasGross = oasMonthlyAt(...)×12` (0 before start ages)
- `other = pension + rental + otherAnnual`
- `need = spendingAnnual`
- RRIF rule: from the end of the year the person turns 71, `rrsp` is a RRIF: `minWithdrawal = rrifPct(age) × rrspBalance` (age 71 uses the 71 row; at exactly 71 use that year's pct on the Jan-1 balance).
- Solve `W` (RRSP/RRIF withdrawal) by **bisection (25 iterations)** such that:
`cash = cpp + oasGross + other + W + nonregW + tfsaW − tax(taxable) − clawback(netIncome) = need`
where `taxable = cpp + oasGross + other + W + 0.5×nonregW` (non-reg drawdowns taxed as 50% capital gains on the withdrawn amount — conservative simplification), `netIncome = taxable` (line 23400 approx), `nonregW = min(nonregBal, max(0, needGapAfterRrsp…))` — order the waterfall **RRSP/RRIF → non-reg → TFSA (last, tax-free)**, with `W = max(userOverride, rrifMin)` when override > 0 (override means "withdraw at least X from RRSP per year"), else `W = solution`.
If user override set: `W = max(override, rrifMin)`; then nonregW/TFSAW fill any remaining gap; if cash still < need → record `unfunded = need − cash`.
- Update balances: `rrsp −= W`; `nonreg −= nonregW`; `tfsa −= tfsaW`; floor at 0. If all hit 0 → `depletedAge = age`, stop drawing, `unfunded` accrues.
- Record per-year: `{age, cpp, oasGross, oasClawback, gis, other, rrifWithdrawal: W, nonregW, tfsaW, taxable, tax, cash, need, unfunded, rrspBal, tfsaBal, nonregBal, totalBal}`.
- Couple mode: run `project` per person with household spending split 50/50 (or 60/40 if one retires later — keep 50/50, document), then merge: household cash = sum; depletion = when combined balances hit 0. OAS: each person's own. GIS: household rate if both 65+.
8. `sustainableSpending(inputs)` → bisection on `spendingAnnual` so that `totalBal ≥ 0` through `lifeExp` (or depletion age > lifeExp). Returns $/yr and $/mo.
9. `recommendations(inputs, projection)` → compute three gap-closers: (a) extra monthly contribution needed to close gap, (b) retirement age that closes gap (+N yrs), (c) spending reduction that closes gap. Each as a concrete number.
Unit tests (Stage 2 exit): `node tests/engine.test.js` — assert: CPP at 60 = 0.64×at65; CPP at 70 = 1.42×at65; RRIF min at 71 = 5.28% of balance; ON tax on $80k taxable ≈ $13–15k (sanity band, print actual); a depleting scenario returns a `depletedAge`; sustainableSpending ≤ spending input when gap exists. Print PASS/FAIL lines; all must pass.
## Stage 3 — UI (`site/index.html`, `site/app.js`, `site/style.css`)
- Single page, no build step, no CDN. Vanilla JS + inline SVG charts. Mobile-first, clean light design, CAD formatting (`$1,234,567`), accessible labels.
- **Questionnaire**: one question per view, big controls, **"Question N of M" + progress bar**, Back / Next buttons. Steps:
1. Age now (25–70)
2. Province/territory (13)
3. Household: just me / I have a partner → if partner: partner age, partner income, partner savings split (3 sub-questions)
4. Your gross income $/yr
5. Your savings: RRSP $, TFSA $, non-registered $ (three inputs, one screen)
6. Monthly contributions + employer match $/mo
7. Retirement age (50–75)
8. Annual spending goal in retirement (today's $)
9. CPP: start age slider 60–70 + optional "my estimated CPP at 65" override
10. OAS start age 65–70
11. RRSP/RRIF withdrawals: "$____ per year (0 = smart auto)"
12. Other income: pension $/yr, rental $/yr, other $/yr
13. Interests in retirement (multi-select chips: cottage/lake, travel, gardening, workshop, grandkids, volunteering, golf, city life) → drives vision image
14. Assumptions (collapsible, defaults shown): real return pre/post, inflation, life expectancy 80–100
- **Building feedback panel** (the core UX requirement): a persistent card directly below the question, visible on every step. After EVERY answer it re-runs `project()` with current inputs (missing values = defaults) and shows 4 live stat chips — **Nest egg at retirement: $X** / **On track: X%** (nestEgg ÷ required, where required = sustainableSpending⁻¹… define: required nest egg = spending × 25 adjusted, or better: run sustainableSpending on current inputs → "your current savings pace supports $Y/yr") / **Money lasts to age Z** / **CPP at 65 ≈ $X/mo** — plus **one contextual insight sentence per question**, e.g.:
- Province → "Top marginal rate in Ontario: 53.53% — RRSP/RRIF withdrawals are taxed as ordinary income."
- Income → "At $85k you're building toward ≈ $1,240/mo CPP at 65 (2026 max $1,447)."
- Savings → "62% of your savings are in registered accounts. TFSA withdrawals are tax-free — handy for bridging before CPP starts."
- Contributions → "You're saving 9% of gross including match; 15% is the common rule of thumb."
- Retirement age → "Retiring at 67 projects ≈ $1.42M (today's dollars) at your current pace."
- Spending → "A $72k/yr goal needs ≈ $1.9M at a 3.5% real return. You're at 74% of that."
- CPP start → "Starting CPP at 60 pays $X/mo (−36% vs 65); starting at 70 pays $Y/mo (+42%). Break-even ≈ age 74."
- OAS → "OAS at 65 ≈ $X/mo. At your income the recovery tax may claw back ≈ $Y/yr." (compute from projection)
- RRSP withdrawal → "$24k/yr from RRSP at 67 costs ≈ $Z/yr in Ontario tax; the engine tops up from TFSA last."
- Other income → income-stack preview sentence.
- Interests → "Nice — we'll match a retirement scene to that."
- Assumptions → "±1% real return swings your nest egg by ≈ ±$X."
- **Results view**: hero stats (nest egg, sustainable spending $/mo, depletion age or "lasts past 95", lifetime tax+clawback drag), graphs G1–G4 (SVG), **Your Retirement Vision** card (matched image + gallery strip + Download link), **3 ways to close the gap** (from `recommendations`), what-if sliders (retireAge, spending, realReturn) with live recalc, disclaimer footer: *"Estimates only — not financial advice. CPP/OAS/GIS figures from canada.ca as of <date>; tax rates <year>."*
- Privacy line on the first screen: **"100% client-side — your answers never leave your browser."**
- Persist answers in `localStorage` (resume on refresh).
## Stage 4 — Vision images (nano-gpt, cheapest model)
1. `KEY=$(grep -m1 '^HERMES_CUSTOM_NANO_GPT_API_KEY=' ~/.hermes/.env | cut -d= -f2)` (never echo the key).
2. Confirm model params: `curl -s https://nano-gpt.com/api/v1/images/models/z-image-turbo/endpoints` (already known: pixel-grid resolutions, prompt ≤1200 chars).
3. Generate **13 images**, `POST https://nano-gpt.com/api/v1/images/generations`, body `{"model":"z-image-turbo","prompt":"...","resolution":"1024*1024","n":1}`. Save `data[0].url` immediately via curl (R2 expiry) into `site/vision/<slug>.png` (check magic bytes; rename .webp if needed and reference correctly). Log `cost` + `remainingBalance` per image into REPORT.md. **Abort the stage if `remainingBalance` < 1.0 or cumulative cost > $2** (site ships with SVG fallbacks).
4. Prompts (tasteful, no text, no specific people; Canadian):
- `hero` — "A calm Canadian lakeside at dawn, mist over still water, pine forest, soft pastel sky, wide serene landscape, photorealistic"
- `muskoka` — "A cozy wooden cottage on a pine-framed lake at golden hour, Muskoka Ontario, loon, warm window light, photorealistic"
- `rockies` — "Turquoise glacial lake with snow-capped Rocky Mountains at sunrise, Banff, photorealistic"
- `maritimes` — "White lighthouse on rugged red sandstone cliffs, Atlantic waves, Peggy's Cove style, soft morning light, photorealistic"
- `prairies` — "Endless golden wheat field under a huge prairie sky with a dramatic summer sunset, Saskatchewan, photorealistic"
- `quebec` — "Colourful historic houses and cobblestone streets of Quebec City old town in autumn, photorealistic"
- `vancouver` — "Vancouver skyline with North Shore mountains and Stanley Park seawall at dusk, photorealistic"
- `toronto` — "Toronto skyline and Lake Ontario waterfront at blue hour, photorealistic"
- `garden` — "A sunny backyard vegetable and flower garden with a small garden shed, Canada, photorealistic"
- `travel` — "Aerial view of a seaplane over a northern Canadian lake surrounded by forest, adventure travel, photorealistic"
- `workshop` — "A bright woodworking workshop with hand tools on a pegboard wall, warm light, hobby, photorealistic"
- `winter` — "A snow-covered log cabin with warm glowing windows in a quiet northern forest, winter evening, photorealistic"
- `dock` — "A wooden dock at sunset with two empty chairs overlooking a quiet lake, peaceful, photorealistic"
5. Province→scene map for the vision card: ON→muskoka, QC→quebec, BC→vancouver, AB→rockies, SK/MB→prairies, NS/NB/NL/PE→maritimes, YT/NT/NU→winter, default→muskoka. Interest chips override: gardening→garden, workshop→workshop, travel→travel, cottage→muskoka/rockies by province, grandkids→dock, city→toronto/vancouver by province. Gallery strip shows all with captions; Download button per image.
6. If the stage aborts: `app.js` detects missing images (fetch HEAD 404) → renders gradient placeholder cards with the scene name. Build this fallback regardless.
## Stage 5 — Gitea repo (source of truth)
1. Create repo via API (token from `~/.git-credentials`, parse with awk/sed, never print):
`curl -s -X POST http://192.168.0.123:3000/api/v1/user/repos -H "Authorization: token <tok>" -H 'Content-Type: application/json' -d '{"name":"retire-planner","private":false,"auto_init":false,"default_branch":"main"}'`
(If 422 "already exists" → reuse it.)
2. In workspace: `git init -b main`, set local identity if unset (`git config user.name "Kris Forbes"; git config user.email "kris@gohosted.ca"`), `echo site/ > .gitignore`? No — commit `site/` and `tests/` and `PLAN.md` and `REPORT.md` (when written). `git remote add origin https://gitea.krisforbes.ca/krisf/retire-planner.git`, commit, push. **Verify no secret strings in the repo** (`git grep -iE 'HERMES_CUSTOM_NANO_GPT_API_KEY=..|cfut_' $(git rev-list --all)` must be empty; the key value must never appear).
## Stage 6 — Unraid web app (nginx, port 8103)
All via `ssh root@192.168.0.123 bash -s` (pipe on stdin):
1. Re-verify 8103 free.
2. `IMG=$(docker inspect blogapp --format '{{.Config.Image}}')` — reuse blogapp's exact nginx image.
3. `mkdir -p /mnt/better/appdata/retireapp/html /mnt/better/appdata/retireapp/config`
4. Write `/mnt/better/appdata/retireapp/config/nginx.conf` (via stdin heredoc): server on 80, root `/usr/share/nginx/html`, `index index.html`, gzip on, `location / { try_files $uri $uri/ =404; }`, `location /vision/ { expires 7d; }`.
5. `rsync -a --delete site/ root@192.168.0.123:/mnt/better/appdata/retireapp/html/` (from the workspace; rsync over ssh works).
6. `docker run -d --name retireapp --restart unless-stopped -p 8103:80 -v /mnt/better/appdata/retireapp/html:/usr/share/nginx/html:ro -v /mnt/better/appdata/retireapp/config/nginx.conf:/etc/nginx/conf.d/default.conf:ro $IMG`
7. Verify: `docker ps --format '{{.Names}} {{.Status}}' | grep retireapp` (Up), `curl -s http://192.168.0.123:8103/ | grep -c 'retire-planner v1'` → ≥1, and `curl -s http://192.168.0.123:8103/engine/engine.js | head -3`.
## Stage 7 — Caddy route (config push — user pre-approved; still do it safely)
1. Backup: `cp /mnt/cache/docker/caddy/config/Caddyfile /mnt/cache/docker/caddy/config/Caddyfile.bak-retire-$(date +%s)`.
2. Append the block (heredoc via stdin, at END of file — top-level blocks can go anywhere):
```
# HERMES: retire
retire.krisforbes.ca {
encode gzip
reverse_proxy 192.168.0.123:8103 {
header_up X-Real-IP {remote_host}
}
}
# END HERMES: retire
```
3. Sanity: `grep -c 'END HERMES: retire' Caddyfile` → 1; then `docker restart CaddyV2`; `sleep 3`; `docker logs --tail 15 CaddyV2` (no config errors).
4. Verify local route: `curl -s -H 'Host: retire.krisforbes.ca' http://192.168.0.123:8103/ | grep -c 'retire-planner v1'` (Host header hits the vhost directly since Caddy proxies to 8103 regardless — this just proves origin serving; the real check is Stage 9).
## Stage 8 — Cloudflare DNS (CNAME retire → dyn.krisforbes.ca, grey cloud)
1. Token: `TOK=$(ssh root@192.168.0.123 "grep -oE 'cfut_[A-Za-z0-9_\-]+' /mnt/cache/docker/caddy/config/caddy/autosave.json | head -1")` — never print it. Zone: `8cf8e4d7035b85deb6ebfe0026da3392`.
2. Check existing: `curl -s "https://api.cloudflare.com/client/v4/zones/$Z/dns_records?name=retire.krisforbes.ca" -H "Authorization: Bearer $TOK"` → if a record exists with content `dyn.krisforbes.ca`, skip creation (log it). If it exists pointing elsewhere, PATCH it.
3. Create: `curl -s -X POST "https://api.cloudflare.com/client/v4/zones/$Z/dns_records" -H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' -d '{"type":"CNAME","name":"retire.krisforbes.ca","content":"dyn.krisforbes.ca","proxied":false,"ttl":1}'` → expect `"success":true`; log the record id into REPORT.md (needed for rollback).
4. Propagation check: `dig +short retire.krisforbes.ca @1.1.1.1` → CNAME dyn.krisforbes.ca → A record(s).
## Stage 9 — Verification (evidence into REPORT.md)
1. Origin: `curl -s http://192.168.0.123:8103/ | grep -o '<title>[^<]*</title>'`.
2. DNS: `dig +short retire.krisforbes.ca` (CNAME → dyn → A).
3. **Off-LAN HTTPS (the real test — hairpin is blocked, do NOT trust a same-LAN curl):** via the Midhurst site proxy: `curl -s -x http://100.96.20.61:8899 -o /dev/null -w '%{http_code}\n' https://retire.krisforbes.ca/` → expect 200 (see `projects/ego-test/site_proxy.py` for how the proxy is started; if it's not running, start it per that project's notes, or use the troublebox `ssh trouble@100.96.20.61 -p 2222 'curl -sI https://retire.krisforbes.ca/'`). First hit may take up to ~60 s if Caddy issues an on-demand LE cert — retry up to 5× with 15 s sleeps. Also fetch the page body off-LAN and grep the `retire-planner v1` marker + `<title>`.
4. Asset checks off-LAN: `/engine/engine.js`, `/style.css`, `/app.js`, one `/vision/*.png` → all 200.
5. Engine smoke in browser context: `node tests/engine.test.js` already passed (Stage 2); additionally `node -e` a full `project()` run with sample inputs and print nestEgg/depletionAge/sustainableSpending — log the numbers.
6. Mobile: `curl -s .../ | grep -c 'name="viewport"'` → 1.
## Stage 10 — REPORT.md + done
Write `REPORT.md` in the workspace root: what was built (file tree), the launch URL, Stage-by-stage checklist with evidence snippets, image-gen spend (sum of `cost`, models used), constants as-of dates, any skipped/failed stages + why, rollback instructions (below), and the done token. Commit + push REPORT.md. **Final output line: `RETIRE_PLANNER_DONE`.**
## Rollback (if ever needed — put this in REPORT.md too)
```bash
# 1. remove Caddy block + restart
ssh root@192.168.0.123 bash -s <<'EOF'
sed -i '/# HERMES: retire/,/# END HERMES: retire/d' /mnt/cache/docker/caddy/config/Caddyfile
docker restart CaddyV2
docker rm -f retireapp
rm -rf /mnt/better/appdata/retireapp
EOF
# 2. delete DNS record (id captured in REPORT.md)
curl -s -X DELETE "https://api.cloudflare.com/client/v4/zones/8cf8e4d7035b85deb6ebfe0026da3392/dns_records/<record-id>" -H "Authorization: Bearer <cftok>"
# 3. repo stays (source of truth); site files stay in the repo
```
## File tree (workspace)
```
retire-planner/
PLAN.md ← this file
site/
index.html ← questionnaire + results + vision, loads app.js
style.css
app.js ← questionnaire flow, feedback panel, charts (SVG), vision card
engine/
canada.js ← Stage 1 constants (value, source, asOf)
engine.js ← Stage 2 pure math (UMD)
vision/ ← Stage 4 generated images (committed; ~13 × ~1-2 MB)
tests/
engine.test.js ← node unit tests
REPORT.md ← Stage 10
```
## Definition of done (checklist for DSH)
- [ ] `node tests/engine.test.js` all PASS
- [ ] Site files built; secrets scan clean; repo pushed to gitea.krisforbes.ca/krisf/retire-planner
- [ ] retireapp container Up on Unraid :8103; origin serves marker
- [ ] Caddyfile block added; CaddyV2 restarted clean
- [ ] CF CNAME retire → dyn.krisforbes.ca (grey cloud) created (or BLOCKER documented)
- [ ] Off-LAN `curl https://retire.krisforbes.ca/` → 200 + marker + title
- [ ] Vision images generated with z-image-turbo (or documented fallback)
- [ ] REPORT.md committed + pushed; final line `RETIRE_PLANNER_DONE`