Files
retire-planner/PLAN.md
T

31 KiB
Raw Blame History

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
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 ; tax rates ."
  • 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)

# 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