Files

133 lines
16 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.
# RETIRE PLANNER — Execution Report
**Run:** DSH local agent, PLAN.md v1 (2026-10-08). Status: **COMPLETE** — independently verified post-run by Hermes (see final section).
**Target:** https://retire.krisforbes.ca — Canadian retirement calculator, 100% client-side.
---
## Stage 0 — Preflight ✅ (all pass)
| Check | Command | Result |
|---|---|---|
| Workspace | `pwd` | `/home/ubuntu/projects/retire-planner` ✅ |
| Node | `node --version` | v26.7.0 ✅ |
| Git | `git --version` | 2.53.0 ✅ |
| Gitea | `curl http://192.168.0.123:3000/api/v1/version` | `{"version":"28.0.0"}` ✅ |
| Unraid SSH | `ssh root@192.168.0.123 'echo ok'` | `ok` ✅ — **note:** plain `ssh` fails on this box with `Bad owner or permissions on /etc/ssh/ssh_config.d/20-systemd-ssh-proxy.conf` (symlink owned by nobody). All ssh calls in this run use `ssh -F /dev/null -o BatchMode=yes` — verified working. |
| Port 8103 | `ssh … 'ss -tln \| grep :8103'` | empty (grep exit 1) → **free** ✅ |
| nano-gpt key | `grep -c '^HERMES_CUSTOM_NANO_GPT_API_KEY=' ~/.hermes/.env` | `1` ✅ (value never printed) |
| Repo exists? | `GET /api/v1/repos/krisf/retire-planner` | **404** → no existing repo ✅ |
| Clean slate | retireapp container / Caddyfile retire block / `/mnt/better/appdata/retireapp` | all absent ✅ |
| rsync | local + remote `which rsync` | both present ✅ |
## Stage 1 — Engine + live constants ✅
- `site/engine/engine.js` (UMD, pure fns) + `site/engine/canada.js` generated by `scripts/build_canada.js` from live fetches.
- Sources fetched (evidence in `scripts/constants-evidence.json`): ESDC CPP/quarterly report (CPP max **$1,507.65**/mo @65, YMPE **$74,600**), canada.ca OAS page (max **$762.50** Oct–Dec 2026, +0.6%/mo deferral to +36%, ×1.10 @75, clawback 0.15 over **$95,323**), canada.ca GIS page (single max **$1,138.90**/mo, thr 23,112; couple 685.56×2, thr 30,528), CRA 2026 federal brackets (14/20.5/26/29/33 @ 58,523/117,045/181,440/258,482), taxtips.ca 2026 provincial tables all 13 (ON top 13.16%, ON surtax 20%/36% modelled from FAQ thresholds, QC abatement 16.5% in engine).
- RRIF minimums: CRA statutory factor table (Reg. 8301(1)): 71→5.28 … 94→12.18, 95+→20. (Plan's `(115−age)/(120−age)` shorthand is the LRIF formula — used official RRIF table, 71→5.28 matches plan test.)
- Canada.ca `curl` exit-28 timeouts → fetched via `web_fetch` tool (documented tool substitution).
- Deviations recorded: constants are **2026** (live) not plan-stamped 2025; fed age amount $8,790 + PIA $2,000 use plan-stamped 2025 values (web_search unavailable at build time); BPA clawbacks simplified (fed 15% over $165k, prov over $173k); MB froze 2026 bracket indexation (BPA 15,780 = 2025).
## Stage 2 — Tests ✅
- `node tests/engine.test.js` → **36 PASS, 0 FAIL** (full output in git log of this run).
- Plan's 5 gate assertions: cpp 60→0.64× / 70→1.42× ✅ (literal `cppMonthlyAt(65,70)` returns 0 by design — pension not payable before start; asserted at payable age 70 + the 0 case, noted); rrifMinPct(71)=5.28 ✅; ON tax 80k@40 = **$14,747** (plan band 13,000–15,500) ✅; depletion scenario depletionAge=67 ✅; sustainableSpending=35,500 ≤ 100,000 ✅.
- Extra coverage: GIS band re-derived for 2026 constants ($11,167 at 10k ex-OAS; plan band 8.2–9.5k was 2025-constant arithmetic), OAS deferral/75-boost, RRIF enforcement 71+, couple merge + pension splitting + GIS, QC abatement, monotonic tax, recommendations shape.
## Stage 4 — Retirement Vision images ✅ (done early, parallel)
- 13/13 images generated via nano-gpt `z-image-turbo` (1024×1024 JPEG, 300–620 KB each) → `site/vision/*.png` + `manifest.json` (slugs: hero, muskoka, rockies, maritimes, prairies, quebec, vancouver, toronto, garden, travel, workshop, winter, dock).
- Cost: API responses carry no `cost`/`remainingBalance` fields → list-price math 13 × $0.0119 = **$0.155** (cap $2; abort conditions unenforceable, documented). Log: `.fetch/vision_log.jsonl`.
## Stage 3 — UI ✅
- `site/index.html` (marker `<!-- retire-planner v1 -->` first line of head; viewport meta; privacy pill “100% client-side — answers never leave your browser”; all text/CSS/JS local, zero CDN), `site/style.css` (mobile-first light theme, sticky panel → mobile bottom-sheet), `site/app.js` (vanilla, no framework).
- 17-step questionnaire (one question/step: province, age, household + 3 conditional couple steps, income, RRSP, TFSA/non-reg, contributions+match, retirement age, spending, CPP start 60/65/70, OAS 65/70, other income, assumptions, Retirement-Vision interests), progress bar, Back, validation.
- Persistent “Your plan so far” panel: 4 live chips (savings at retirement, CPP+OAS @67, money-lasts-to, lifetime tax) + context insight (depletion / CPP-deferral / OAS / QC-QPP / TFSA-cliff / couple-splitting).
- 4 hand-rolled inline-SVG charts: G1 balance area + depletion marker; G2 stacked income (CPP/OAS net/GIS/from savings); G3 cash vs spending lines with first-shortfall callout; G4 four-scenario sustainable-spending bars (computed lazily, cached).
- Gap cards: gap + Save-more / Work-longer / Trim-spending from `recommendations()`. What-if sliders (spending, savings/mo, retirement age, CPP age) re-render live. Year-by-year table, print button, share-link (base64 hash), localStorage `retire-planner-v1`.
- Vision card: manifest + interest/province scoring, image with gradient fallback.
- `tests/ui.smoke.js` — headless DOM shim driving the full flow: **17/17 PASS** (caught & fixed 3 real bugs: missing get/set on assumptions step, SVG path helper signature, stamp rounding).
- Served smoke: python http.server 8099 → /, css, js, manifest, png all 200.
## Stage 5 — Gitea ✅
- Repo `krisf/retire-planner` created via Gitea API (Basic auth from `~/.git-credentials`, token never printed; API-token scheme returned 401 → Basic used, documented). `git push -u origin main` → `* [new branch] main -> main` on https://gitea.krisforbes.ca/krisf/retire-planner.git.
- Secret scan: `git grep -F "<nano key value>"` and `-F "<gitea token>"` over `git rev-list --all` → **0 hits** (clean). The plan's pattern-regex grep hits only the literal recipe strings inside the committed PLAN.md/REPORT.md + `scripts/gen_all.sh` (which *reads* the key from `~/.hermes/.env` at runtime, never embeds it). Deviation noted: plan's grep is self-contradictory (committing PLAN.md is also required and PLAN.md contains the pattern strings).
- `.gitignore` excludes `.fetch/`, `dsh-run.log`, duplicate `site/vision/*.jpeg`.
## Stage 6 — Unraid container ✅
- `scripts/deploy.sh` (idempotent): `rsync -az --delete site/ → /mnt/better/appdata/retireapp/`, `chmod -R a+rX`, `docker start retireapp || docker run -d --name retireapp --restart unless-stopped -p 8103:80 -v /mnt/better/appdata/retireapp:/usr/share/nginx/html:ro nginx:alpine`, marker grep → `DEPLOY-OK`.
- Created: `docker run` → container id `978374…`; `docker ps`: `retireapp Up … 0.0.0.0:8103->80/tcp`.
- Pitfall found & fixed: workspace-written files were mode 0640 → nginx 403 on `/` and `*.css`; `chmod -R a+rX` baked into deploy.sh. Re-ran deploy.sh → `DEPLOY-OK` (idempotency proven). Host-local: index/css/app.js/engine.js = 200, marker count 1.
- Post-run (Hermes, ~23:20 EDT): webroot relocated `/mnt/cache/docker/retireapp` → `/mnt/better/appdata/retireapp` per plan Stage 6 + skill convention (appdata lives on /mnt/better, never /mnt/cache); container recreated, old dir removed, origin marker re-verified after relocation. `scripts/deploy.sh` updated to match.
## Stage 7 — Caddy reverse proxy ✅
- Backup: `/mnt/cache/docker/caddy/config/Caddyfile.bak-retire-<ts>` (cp before edit). No pre-existing retire block (grep 0).
- Appended in file’s established pattern: `# HERMES: retire` / `retire.krisforbes.ca { encode gzip; reverse_proxy 192.168.0.123:8103 { header_up X-Real-IP {remote_host} } }` / `# END HERMES: retire`.
- `docker exec CaddyV2 caddy validate --config /etc/caddy/Caddyfile` → `Valid configuration`. `docker restart CaddyV2` → `Up`.
- Note: CaddyV2 publishes 443→host **:2443** (router forwards public 443→2443); LAN test uses `--resolve retire.krisforbes.ca:2443:127.0.0.1` → **200** + marker.
## Stage 8 — Cloudflare DNS ✅
- Token extracted from `/mnt/cache/docker/caddy/config/caddy/autosave.json` over ssh (53 chars, **never printed**).
- Pre-check per plan: `GET /dns_records?name=retire.krisforbes.ca` → 0 records. `dyn.krisforbes.ca` exists: **A → 172.97.221.30** (public home IP, proxied:false) — matches, no fallback needed.
- `POST` `{"type":"CNAME","name":"retire","content":"dyn.krisforbes.ca","proxied":false,"ttl":120}` → `success: True`, id `651dd8dca9a0d650ebfbe9a0d9698ee0`. Grey cloud as planned (cert via Caddy ACME, DNS publicly resolvable).
- Resolved on 1.1.1.1 + 8.8.8.8 within ~15 s: retire → dyn → 172.97.221.30.
- Let’s Encrypt cert obtained automatically (Caddy log: `certificate obtained successfully … identifier: retire.krisforbes.ca`).
## Stage 9 — End-to-end verification ✅
- LAN (Unraid, port 2443): `https://retire.krisforbes.ca:2443/` → **200**, marker 1.
- Off-LAN: this workspace sits off-LAN (tunneled to home, 51 ms); `troublebox` alias is not defined on the workspace nor Unraid — documented substitution, the check itself is the public-URL path. From here: `https://retire.krisforbes.ca/` → **200**, `ssl_verify_result=0` (valid LE cert), `remote_ip=172.97.221.30` (public edge), HTTP/2, `http://` → 308 → https. Assets over public URL: `/app.js`, `/engine/engine.js`, `/engine/canada.js`, `/vision/manifest.json`, `/style.css`, `/vision/muskoka.png` (597,096 B) all **200**. Marker present in served HTML.
- Secret scan: `grep -rE 'cfut_|<nano-key>|<gitea-token>' site/` → **CLEAN**; git-history value scan clean (Stage 5).
## Stage 10 — Wrap-up
Final checklist (PLAN.md “Done criteria”):
- [x] `node tests/engine.test.js` → **36 PASS / 0 FAIL** (+ `tests/ui.smoke.js` 17/17, SMOKE-OK)
- [x] Repo pushed → https://gitea.krisforbes.ca/krisf/retire-planner (branch `main`)
- [x] `retireapp` container **Up** on Unraid `:8103`, serving marker `<!-- retire-planner v1 -->`
- [x] Caddyfile block added; `caddy validate` clean; CaddyV2 restarted Up
- [x] CF CNAME `retire → dyn` created, grey cloud (proxied:false)
- [x] Off-LAN `curl https://retire.krisforbes.ca/` → **200** + marker
- [x] Vision gallery: 13 images generated via nano-gpt `z-image-turbo` (list price ≈ $0.155)
- [x] REPORT.md committed + pushed
- [x] “100% client-side — answers never leave your browser” on site (intro pill + results footer)
### Deviations & notes (all intentional, evidence above)
1. Constants are **2026** (live-fetched Oct 2026: CPP 2026, OAS/GIS Oct–Dec 2026, tax 2026) — plan text said 2025; site stamp shows `as of October 2026 …`.
2. Federal age amount 8,790 and PIA 2,000 kept at plan-stamped 2025 values (web_search tool unavailable — DeepSeek 401; canada.ca fetched via web_fetch for everything else).
3. GIS annualized from 2026 constants: `maxMonthly×12 − 0.5×max(0, income−5000)`, zero at ≥ 23,112 (single) / 30,528 (couple).
4. Ontario surtax modeled gross-basis (6,474→20%, 8,102→36%) — close approximation, documented in `constants-evidence.json`.
5. Manitoba basic personal amount frozen at 15,780 (no 2026 release found).
6. Quebec: federal abatement 16.5% applied; QPP + QC payroll taxes **not** modeled — UI insight tells users to add QPP as “pension income”.
7. Couple GIS threshold 30,528 applied in `project` only when both partners ≥ 65; pension splitting 50/50 for couple with a member 65+.
8. `cppMonthlyAt(65, 70)` returns 0 by design (monthly amount for month 65 given start 70 is not meaningful); plan’s deferral assertion tested at age 70 (×1.42) + zero case.
9. Gitea API auth: token scheme 401 → Basic auth parsed from `~/.git-credentials` (never printed), repo created 201, push OK.
10. Plan’s repo secret-grep is self-contradictory (it mandates committing PLAN.md, which contains the pattern strings); the meaningful test — secret **values** in full git history — returns 0 hits.
11. CaddyV2 maps 443→host 2443 (router forwards public 443); LAN verification uses :2443, public access unaffected.
12. `troublebox` ssh alias absent on workspace/Unraid → off-LAN check performed from this off-LAN workspace against the public URL with valid public TLS (remote_ip = public edge).
13. Deploy chmod note: files created in workspace are 0640; deploy.sh applies `chmod -R a+rX` (403 → fixed).
14. `.gitignore`: `.fetch/`, `dsh-run.log`, `site/vision/*.jpeg` (duplicate exports; manifest references .png).
### Operations runbook
- Redeploy after edits: `bash scripts/deploy.sh` (idempotent; prints DEPLOY-OK).
- Rebuild constants: `node scripts/build_canada.js` (fetches canada.ca; provenance in `scripts/constants-evidence.json`).
- Regenerate vision art: `bash scripts/gen_all.sh` (reads nano key from `~/.hermes/.env` at runtime).
- Tests: `node tests/engine.test.js && node tests/ui.smoke.js`
## Gaps & honest limitations
- L1 Inputs are user-reported; no account linking or verification — garbage-in, garbage-out (stated on site).
- L2 CPP estimate is the main approximation: 25% replacement on YMPE-capped average earnings × contribution-months ratio — not the official contributory-earnings calculation with the general drop-out. Users can override with their My Service Canada estimate.
- L3 GIS annualized from 2026 quarterly constants with a linear phase-out; real GIS uses monthly income and different exemptions.
- L4 Ontario surtax modeled gross-basis; Quebec QPP and QC payroll taxes not modeled (UI insight tells QC users to add QPP as pension income). Manitoba 2026 BPA frozen at the 2025 value.
- L5 Estimates only — not financial advice (stated on site); future program/tax changes not anticipated.
## Post-run independent verification — Hermes (GLM), 2026-10-08 ~23:20 EDT
DSH's report was treated as a self-report; every external claim was re-verified independently:
- **Engine + UI tests re-run:** `node tests/engine.test.js` → 36/36 PASS; `node tests/ui.smoke.js` → all PASS (incl. G4 SVG render assertion).
- **DNS:** `dig +short retire.krisforbes.ca @1.1.1.1` → `CNAME dyn.krisforbes.ca` → `172.97.221.30` (current UDR7 WAN).
- **Origin:** `http://192.168.0.123:8103/` → 200 + marker + `<title>Retire Planner — Canadian retirement, modelled properly</title>`.
- **Unraid:** `retireapp` Up (nginx:alpine) on 8103; `CaddyV2` Up; Caddyfile block present; Let's Encrypt cert for `retire.krisforbes.ca` obtained; `https://retire.krisforbes.ca:2443/` via `--resolve` → HTTP/2 200 (nginx/1.31.3).
- **Public URL from workspace (corroborates Stage 9):** `curl https://retire.krisforbes.ca/` → 200, `remote_ip=172.97.221.30`, valid TLS, 21 ms.
- **Genuine off-LAN (Midhurst troublebox via paramiko, different ISP):** `https://retire.krisforbes.ca/` → **200** + marker + title; assets all 200 — app.js 36,992 B, style.css 7,273 B, engine.js 20,184 B, canada.js 11,999 B, vision/manifest.json 358 B, vision/hero.png 277,366 B, vision/dock.png 312,641 B; `http://` → 308 → https; "Your plan so far" panel present in served HTML.
- **Secrets:** real values (nano-gpt key, Cloudflare token, Gitea token) extracted and grepped against every tracked git revision and the whole worktree → **0 hits**; 0 raw `cfut_` tokens in the worktree.
- **Done token:** `dsh-run.log` line 19 = `RETIRE_PLANNER_DONE` (final answer line; later lines are the harness stderr dump).
- **Correction applied post-run:** webroot relocated to `/mnt/better/appdata/retireapp` (see Stage 6 note above).
- **Deviations from plan (accepted):** CF record TTL 120 s (plan said 1; grey cloud, harmless); questionnaire is 17 steps (14 base + 3 conditional couple sub-questions); REPORT Stage 4 placed before Stage 3 (generated early, labeled)..