trainbud 0.5.0 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (152) hide show
  1. package/.env.example +11 -11
  2. package/CHANGELOG.md +757 -675
  3. package/QUICKSTART.md +185 -182
  4. package/README.md +329 -322
  5. package/dist/appDb.d.ts +40 -1
  6. package/dist/appDb.js +120 -51
  7. package/dist/cli.js +47 -0
  8. package/dist/dashboard.js +820 -820
  9. package/dist/dashboardCharts.js +42 -42
  10. package/dist/deviceTokens.d.ts +29 -0
  11. package/dist/deviceTokens.js +49 -0
  12. package/dist/history/context.js +35 -35
  13. package/dist/history/schema.js +66 -66
  14. package/dist/httpServer.js +64 -1
  15. package/dist/pairApi.js +11 -2
  16. package/dist/promptApi.js +5 -5
  17. package/dist/usage.js +17 -17
  18. package/package.json +82 -77
  19. package/dist/appDb.d.ts.map +0 -1
  20. package/dist/appDb.js.map +0 -1
  21. package/dist/check.d.ts.map +0 -1
  22. package/dist/check.js.map +0 -1
  23. package/dist/cli.d.ts.map +0 -1
  24. package/dist/cli.js.map +0 -1
  25. package/dist/config.d.ts.map +0 -1
  26. package/dist/config.js.map +0 -1
  27. package/dist/dashboard.d.ts.map +0 -1
  28. package/dist/dashboard.js.map +0 -1
  29. package/dist/dashboardCharts.d.ts.map +0 -1
  30. package/dist/dashboardCharts.js.map +0 -1
  31. package/dist/dashboardData.d.ts.map +0 -1
  32. package/dist/dashboardData.js.map +0 -1
  33. package/dist/detect/baseline.d.ts.map +0 -1
  34. package/dist/detect/baseline.js.map +0 -1
  35. package/dist/detect/countdown.d.ts.map +0 -1
  36. package/dist/detect/countdown.js.map +0 -1
  37. package/dist/detect/detectors.d.ts.map +0 -1
  38. package/dist/detect/detectors.js.map +0 -1
  39. package/dist/detect/findings.d.ts.map +0 -1
  40. package/dist/detect/findings.js.map +0 -1
  41. package/dist/detect/forecast.d.ts.map +0 -1
  42. package/dist/detect/forecast.js.map +0 -1
  43. package/dist/detect/index.d.ts.map +0 -1
  44. package/dist/detect/index.js.map +0 -1
  45. package/dist/detect/sleepQuality.d.ts.map +0 -1
  46. package/dist/detect/sleepQuality.js.map +0 -1
  47. package/dist/detect/trimp.d.ts.map +0 -1
  48. package/dist/detect/trimp.js.map +0 -1
  49. package/dist/detect/week.d.ts.map +0 -1
  50. package/dist/detect/week.js.map +0 -1
  51. package/dist/garmin/auth.d.ts.map +0 -1
  52. package/dist/garmin/auth.js.map +0 -1
  53. package/dist/garmin/cache.d.ts.map +0 -1
  54. package/dist/garmin/cache.js.map +0 -1
  55. package/dist/garmin/client.d.ts.map +0 -1
  56. package/dist/garmin/client.js.map +0 -1
  57. package/dist/garmin/daily.d.ts.map +0 -1
  58. package/dist/garmin/daily.js.map +0 -1
  59. package/dist/garmin/garminApiTypes.d.ts.map +0 -1
  60. package/dist/garmin/garminApiTypes.js.map +0 -1
  61. package/dist/garmin/garminConnect.d.ts.map +0 -1
  62. package/dist/garmin/garminConnect.js.map +0 -1
  63. package/dist/garmin/partial.d.ts.map +0 -1
  64. package/dist/garmin/partial.js.map +0 -1
  65. package/dist/garmin/rawApi.d.ts.map +0 -1
  66. package/dist/garmin/rawApi.js.map +0 -1
  67. package/dist/garmin/types.d.ts.map +0 -1
  68. package/dist/garmin/types.js.map +0 -1
  69. package/dist/history/capture.d.ts.map +0 -1
  70. package/dist/history/capture.js.map +0 -1
  71. package/dist/history/context.d.ts.map +0 -1
  72. package/dist/history/context.js.map +0 -1
  73. package/dist/history/fallback.d.ts.map +0 -1
  74. package/dist/history/fallback.js.map +0 -1
  75. package/dist/history/ingest.d.ts.map +0 -1
  76. package/dist/history/ingest.js.map +0 -1
  77. package/dist/history/scheduler.d.ts.map +0 -1
  78. package/dist/history/scheduler.js.map +0 -1
  79. package/dist/history/schema.d.ts.map +0 -1
  80. package/dist/history/schema.js.map +0 -1
  81. package/dist/history/store.d.ts.map +0 -1
  82. package/dist/history/store.js.map +0 -1
  83. package/dist/httpServer.d.ts.map +0 -1
  84. package/dist/httpServer.js.map +0 -1
  85. package/dist/index.d.ts.map +0 -1
  86. package/dist/index.js.map +0 -1
  87. package/dist/mcpConfig.d.ts.map +0 -1
  88. package/dist/mcpConfig.js.map +0 -1
  89. package/dist/pairApi.d.ts.map +0 -1
  90. package/dist/pairApi.js.map +0 -1
  91. package/dist/paths.d.ts.map +0 -1
  92. package/dist/paths.js.map +0 -1
  93. package/dist/profile.d.ts.map +0 -1
  94. package/dist/profile.js.map +0 -1
  95. package/dist/promptApi.d.ts.map +0 -1
  96. package/dist/promptApi.js.map +0 -1
  97. package/dist/promptSuggestions.d.ts.map +0 -1
  98. package/dist/promptSuggestions.js.map +0 -1
  99. package/dist/selfTest.d.ts.map +0 -1
  100. package/dist/selfTest.js.map +0 -1
  101. package/dist/server.d.ts.map +0 -1
  102. package/dist/server.js.map +0 -1
  103. package/dist/setup.d.ts.map +0 -1
  104. package/dist/setup.js.map +0 -1
  105. package/dist/toolErrors.d.ts.map +0 -1
  106. package/dist/toolErrors.js.map +0 -1
  107. package/dist/tools/activities.d.ts.map +0 -1
  108. package/dist/tools/activities.js.map +0 -1
  109. package/dist/tools/bodyComposition.d.ts.map +0 -1
  110. package/dist/tools/bodyComposition.js.map +0 -1
  111. package/dist/tools/context.d.ts.map +0 -1
  112. package/dist/tools/context.js.map +0 -1
  113. package/dist/tools/findings.d.ts.map +0 -1
  114. package/dist/tools/findings.js.map +0 -1
  115. package/dist/tools/heartRate.d.ts.map +0 -1
  116. package/dist/tools/heartRate.js.map +0 -1
  117. package/dist/tools/index.d.ts.map +0 -1
  118. package/dist/tools/index.js.map +0 -1
  119. package/dist/tools/payloads.d.ts.map +0 -1
  120. package/dist/tools/payloads.js.map +0 -1
  121. package/dist/tools/recovery.d.ts.map +0 -1
  122. package/dist/tools/recovery.js.map +0 -1
  123. package/dist/tools/sleep.d.ts.map +0 -1
  124. package/dist/tools/sleep.js.map +0 -1
  125. package/dist/tools/stress.d.ts.map +0 -1
  126. package/dist/tools/stress.js.map +0 -1
  127. package/dist/tools/trainingInsights.d.ts.map +0 -1
  128. package/dist/tools/trainingInsights.js.map +0 -1
  129. package/dist/tools/types.d.ts.map +0 -1
  130. package/dist/tools/types.js.map +0 -1
  131. package/dist/tools/vo2Max.d.ts.map +0 -1
  132. package/dist/tools/vo2Max.js.map +0 -1
  133. package/dist/tools/week.d.ts.map +0 -1
  134. package/dist/tools/week.js.map +0 -1
  135. package/dist/usage.d.ts.map +0 -1
  136. package/dist/usage.js.map +0 -1
  137. package/dist/utils/batch.d.ts.map +0 -1
  138. package/dist/utils/batch.js.map +0 -1
  139. package/dist/utils/helpers.d.ts.map +0 -1
  140. package/dist/utils/helpers.js.map +0 -1
  141. package/dist/utils/jsonFile.d.ts.map +0 -1
  142. package/dist/utils/jsonFile.js.map +0 -1
  143. package/dist/utils/logger.d.ts.map +0 -1
  144. package/dist/utils/logger.js.map +0 -1
  145. package/dist/utils/secretFile.d.ts.map +0 -1
  146. package/dist/utils/secretFile.js.map +0 -1
  147. package/dist/utils/stdio.d.ts.map +0 -1
  148. package/dist/utils/stdio.js.map +0 -1
  149. package/dist/version.d.ts.map +0 -1
  150. package/dist/version.js.map +0 -1
  151. package/dist/watchApi.d.ts.map +0 -1
  152. package/dist/watchApi.js.map +0 -1
package/CHANGELOG.md CHANGED
@@ -1,675 +1,757 @@
1
- # Changelog
2
-
3
- All notable changes to this project will be documented in this file.
4
-
5
- ## [Unreleased] — server 0.5.0 · watch 2.0.0
6
-
7
- ### Securitythe key stopped living in the address bar
8
-
9
- - **The dashboard trades `?token=` for a session cookie and redirects to a clean
10
- URL.** The API key opened the dashboard and then stayed in the address bar for
11
- the whole session, which put it in browser history, in the tunnel provider's
12
- access log, and in every screenshot of the page and this page is about to be
13
- screenshotted by people announcing it. The token still gets you in; what carries
14
- you afterwards is an opaque 32-byte session id held in the server's memory, so it
15
- grants nothing after a restart and cannot be replayed against another server.
16
- `HttpOnly` keeps page script off it and `SameSite=Lax` is what stops cookie auth
17
- from adding the CSRF that a Bearer header never had. `Secure` goes on only when
18
- the request arrived over TLS, because a browser silently discards a Secure cookie
19
- on `http://127.0.0.1` which is how the local dashboard is reached.
20
- A Bearer header still works untouched, and is what the watch and MCP clients use.
21
- - **The paired watch holds the API key itself, and the docs now say so.** Approving
22
- a pairing hands the watch the same credential that opens the dashboard and `/mcp`,
23
- not a narrower per-device token. The privacy policy implied a scoped token; it now
24
- states what is actually handed over and how to revoke it. A per-device token is
25
- still owed.
26
- - **`scripts/start-watch-stack.ps1` no longer defaults to the maintainer's own ngrok
27
- domain.** Anyone who cloned the repo and ran the script published a tunnel pointed
28
- at somebody else's address. It reads `TRAINBUD_NGROK_DOMAIN` and refuses to guess.
29
-
30
- ### Changed the README a launch link lands on
31
-
32
- - Quick start leads with `npx trainbud setup`; the clone-and-link path moves into a
33
- "from source" fold. The old first instruction was four commands and a caveat.
34
- - A **"What TrainBud is not"** section: no hosted service, not an official Garmin
35
- integration, not MFA-compatible, and the AI features bill to your own key.
36
- - The test count said 33. It is 551.
37
-
38
- TrainBud knew a great deal about your body and nothing about you. This release
39
- adds the half that was missing, and takes four decisions off the watch that it
40
- was never in a position to make.
41
-
42
- ### Added — a profile, and a product that knows who it is talking to
43
-
44
- - **`src/profile.ts`** — name, units, primary sport, weekly goal, the bands at
45
- which a number turns amber or red, the watch card order, and the AI's voice.
46
- One row of `app.db`, read by every surface. Reads salvage field by field
47
- (a settings row that is not JSON is wreckage, not configuration, and
48
- `getProfile` sits on the path of every watch fetch); writes are strict and
49
- name the field they refused.
50
- - **Personal thresholds decide colour everywhere.** Resting heart rate is graded
51
- on the distance from *your own* median rather than on the rate — 58 bpm is
52
- unremarkable for one person and a warning for another.
53
- - **Card order and visibility** are set in the dashboard and live on the watch's
54
- next fetch. No Connect IQ settings sync, no store update.
55
-
56
- ### Addedwhat the AI costs, which nothing had ever counted
57
-
58
- - Every AI call records its tokens and its price. Month-to-date spend is a
59
- **calendar month**, not a rolling thirty days: a cap on a sliding window never
60
- resets, and the number is compared against a bill that runs in months.
61
- - An optional monthly cap **refuses the request before the money is spent**, and
62
- says so on the watch instead of after a round trip. No cap is set by default,
63
- so nothing is ever blocked out of the box.
64
- - **An unknown price is null, never zero.** A model this build has no rate for
65
- records its tokens and leaves the cost unknown, because a call priced at zero
66
- is a cap that can never trip. Every total carries the count of calls it could
67
- not price and says when it is a floor rather than a total.
68
-
69
- ### Added the Ask menu you write yourself
70
-
71
- Up to five questions, set in the dashboard, that lead the Ask menu on the wrist in
72
- the order you put them ahead of the generated ones, in every state, including the
73
- first fortnight when the app has nothing of its own worth asking about. Whatever
74
- slots are left still fill from what actually fired, so "Why is my resting HR up?"
75
- keeps its place on the day it is up.
76
-
77
- **`ai.customPrompts` had existed since the profile landed and nothing read it.** It
78
- was in the schema, it validated, it saved and no dashboard control wrote it and no
79
- code path consumed it, so a question typed against that API could never have appeared
80
- on a watch. The bounds it shipped with said as much: eight questions for a five-slot
81
- menu, 120 characters for a line the watch draws at 32. Both now come from
82
- `promptSuggestions.ts`, which owns the menu, and the profile refuses to store a
83
- question that could not be shown.
84
-
85
- ### Added a training dashboard
86
-
87
- - Phone first, because the pairing flow already was. Findings, the week against
88
- last week, resting heart rate and sleep against your own median, spend, and
89
- every setting above.
90
- - Drawn from local SQLite and nothing else — no Garmin round trip, so it paints
91
- instantly and works with an expired session. Charts are inline SVG built by
92
- pure functions; there is no CDN, because a script tag would tell a third party
93
- every time you opened your own health dashboard.
94
- - **A missing day is a gap, not a zero.** Lines break at an absence, days are
95
- placed by date rather than by array position, and a chart with nothing to draw
96
- says so instead of rendering an empty axis that reads as "zero, every day".
97
-
98
- ### Added local feature counters
99
-
100
- Which of the nine cards anyone opens, and how often the Ask path runs. Counted
101
- on a request the watch was already making rather than per swipe. On by default
102
- because there is no endpoint to send them to; switchable off, with a delete
103
- button, in the dashboard under Privacy.
104
-
105
- ### Changed the watch draws state, it no longer decides it
106
-
107
- `recoveryColor`, `sleepColor`, `stressColor` and `heartRateColor` are gone from
108
- the Monkey C. The server grades; the watch colours. That removed three defects
109
- rather than moving code: the Overview grid graded two of its four cells and
110
- recovered the number by re-parsing its own formatted string (`parseNumber("6.3h")`
111
- returns 6); `heartRateColor` painted one line holding both the resting and the
112
- maximum heart rate while grading only the resting one; and a missing value fell
113
- through to white, which is also what "graded and unremarkable" looks like.
114
-
115
- ### Fixed on the wrist
116
-
117
- - A finding that does not fit is **counted, not dropped**. The same payload
118
- showed two findings on a Fenix and one, silently, on a Forerunner 55.
119
- - The recovery ring no longer strikes through the heart-rate line beneath it.
120
- A circle narrows as it descends; the label had been tested against that since
121
- 1.3.2 and the line under it never was.
122
- - Severity is a marker, not the text colour. A warning arrived as five lines of
123
- red on black — the least legible combination there is on a transflective
124
- screen in daylight.
125
- - A big **fall** in training load is marked. Only a rise was, so a 40% collapse
126
- the shape of an illness week passed without a mark.
127
- - Colours are chosen against the worst screen in the manifest. The Forerunner 55
128
- has an eight-colour palette and rounds everything to the nearest entry, which
129
- is why "good" is `#4CD964`: the mint the dashboard shipped with rounds to CYAN
130
- there.
131
-
132
- ### Fixed verification
133
-
134
- The Forerunner 55 could not finish the screen tour. It died partway through,
135
- twice, reported as "keypress did not register" which reads exactly like the
136
- simulator being flaky, and was not: the tour allocated a second full payload
137
- while the first was still live, and on that device it is the difference between
138
- fitting and not. All 28 states now capture on fr55 and fenix847mm.
139
-
140
- ### Fixed store screenshots the app actually drew
141
-
142
- The Connect IQ listing still carried five 1.3.x captures: an Overview grid from
143
- before all four cells were graded, and findings as coloured body text rather
144
- than white text with a severity marker. Replacing them had already failed twice
145
- in one day once because the screen tour paints its state counter over the app
146
- ("9/28" above the title, in five images that reached the tree), once because the
147
- crop was measured inwards from the simulator window instead of from the device,
148
- so it sat off-centre and carried the watch case, and the vendor's wordmark
149
- printed on it, into a picture meant to be the screen.
150
-
151
- - **`build.ps1 -Screens -NoLabel`** compiles the counter out through a
152
- `ScreenTour.labelVisible()` annotation pair, so no keypress can bring it back.
153
- - **`capture-sim.ps1 -Display -Device <id>`** locates the device artwork in the
154
- window and adds `display.location` from the SDK's own `simulator.json`,
155
- writing the display at native resolution 390×390 on fr70 with the corners
156
- a round screen cannot physically show blacked out. If the artwork is not
157
- there at 1:1 it refuses, rather than cropping something plausible.
158
- - **`capture-store-shots.ps1`** drives the tour, refuses any build but the
159
- label-free one, re-checks every saved image for the counter's colour, and
160
- stops the run when a keypress does not land instead of naming every remaining
161
- file after the state before it.
162
-
163
- `ciq/store/screenshots/store/` is now Today, Week, Recovery, Overview and Ask as
164
- 2.0.0 draws them. The five whole-watch 558x558 images that sat beside them are
165
- gone: nothing referenced them, they were 1.3.x too, and they were pictures of a
166
- watch rather than of a screen.
167
-
168
- ---
169
-
170
- ## [0.4.1] server 0.4.1 · watch 1.4.0
171
-
172
- "The AI says it has no access to my data" was reported once and fixed once, in
173
- `bb1fae8`. This release is what an audit found behind it: **four more distinct
174
- causes of that same sentence**, none of which the first fix touched, plus six
175
- defects the sweep turned up alongside them. Every one was verified against this
176
- machine's real install, whose Garmin session is genuinely gone and whose record
177
- genuinely stops on 2026-08-21 which turned out to be the most valuable test
178
- fixture in the project.
179
-
180
- ### Fixedthe AI could not see the user's data, four more ways
181
-
182
- - **The one tool that reads the store told the model the store was empty.**
183
- `get_findings` and `trainbud findings`, on a store holding 74 days:
184
- *"Still gathering data 74 of the 14 days needed."* `coverage.ready` carries
185
- two refusals that need opposite answers — not enough history yet, and plenty
186
- of history that stops weeks ago — and `bb1fae8` taught exactly one of the four
187
- surfaces to tell them apart. `FindingsPayload.coverage` was also typed
188
- `{ days, ready }` while the value assigned to it carried four fields, so the
189
- renderer could not have read `throughDate` had it wanted to. One
190
- `describeFindingsCoverage`, read by all four surfaces.
191
-
192
- - **Nothing on the read path had ever opened the history store.** Every
193
- per-metric tool read Garmin and nothing else, so an expired session meant
194
- `get_sleep_data` threw, all six of the watch summary's payload calls returned
195
- null, and an MCP client got a tool error with 598 measurements and 2139
196
- archived Connect responses on disk. Tools now fall back to `raw_payload`,
197
- re-run through the *same* mapper the live path uses so a stored night keeps
198
- its stage breakdown, and to `daily_metric` behind it for days older than the
199
- 180-day archive. **The window moves too**: with the record ending 08-21, "the
200
- last 7 nights" selects seven days that were never recorded, so serving the
201
- store changed nothing until the window could slide to where the data is and
202
- say so, in capitals, so no model reads a fortnight-old week as this week.
203
-
204
- - **A dependency printed English into the middle of the MCP protocol stream.**
205
- `garmin-connect` calls `console.log('login page title:', …)` on every
206
- re-login, and `console.log` writes to stdout, which is the JSON-RPC channel
207
- for `trainbud start`. Claude Desktop and Cursor the two clients `setup`
208
- configures lose the session on any re-login inside a tool call. Stdout is
209
- now claimed for the life of the process rather than one more `console` method
210
- being patched at one more call site.
211
-
212
- - **Any CLI command killed the answer the watch was waiting for.**
213
- `reconcilePromptJobs` ran inside `getDb()`, so the first time *any* process
214
- opened `app.db` it flipped every in-flight job to *"The server stopped before
215
- this answer came back."* Running `trainbud doctor` while the watch waited did
216
- exactly that. It now runs once, from `serve`, and never touches a job younger
217
- than five minutes.
218
-
219
- ### Fixed numbers the app stated and had not measured
220
-
221
- - **"This week against last week" compared seven days against eight.**
222
- `series(kind, n)` spans `n + 1` days inclusive, so `WEEK_DAYS * 2` split at
223
- `today - 7` gave halves of 7 and 8. The TRIMP load line beside it summed two
224
- correct seven-day windows, so a single card disagreed with itself.
225
-
226
- - **The sleep card called a fortnight-old night "last night".**
227
- `hours.slice(-7)` is the last seven *rows*, which are seven nights only if the
228
- watch was worn every night. Windowed by date now, like `detectors.ts` and
229
- `week.ts` already were; a week with nothing in it reports no debt rather than
230
- a debt of zero.
231
-
232
- - **`status` reported 1590 empty days for a store covering 366 dates.** The
233
- count was over `ingest_day` rows, which are keyed (date, source) across six
234
- sources. Now 292 empty days against 74 measured figures that add up.
235
-
236
- ### Fixed security and data loss
237
-
238
- - **The crash handler wrote the API key to the log the request logger had
239
- stopped writing.** The dashboard authenticates with `?token=`, and
240
- `logger.error({ error, url: req.url })` was raw. The occurrence already
241
- present in `.trainbud/mcp.log` has been scrubbed. **Rotate the key**: a
242
- credential that has sat in a world-readable file is exposed whatever the file
243
- says now.
244
-
245
- - **Only `app.db` was hardened to 0600.** `history.db` a year of sleep, heart
246
- rate and HRV — `cache.db` and the log file were all at the default 0644.
247
-
248
- - **`setup` rewrote the user's Claude Desktop config keeping only our own key.**
249
- `readMcpConfig` returned `{ mcpServers }` and the write dropped everything
250
- else; a config with no `mcpServers` key at all was replaced wholesale. The
251
- file is now read whole and copied to `<name>.bak.json` before being touched.
252
-
253
- - **The retry path erased the rate-limit block it had just walked into.**
254
- `withGarminClient` called `resetGarminClient`, which clears the persisted
255
- cooldown, and then forced a login into the live block on the single path
256
- that block exists to guard. Splitting "forget the session" from "forget the
257
- limit" also lets the backoff ladder escalate, which it never could.
258
-
259
- - **The cooldown test wrote a live five-minute Garmin block into the
260
- developer's own database.** Found by attempting a backfill after the suite
261
- passed. Tests no longer touch the real `app.db`.
262
-
263
- ### Notes
264
-
265
- Two regression tests in this release passed against the deliberately broken
266
- build before being rebuilt. Reverting the fix remains the only thing that
267
- separates a regression test from a decoration.
268
-
269
- ## [0.4.0] server 0.4.0 · watch 1.4.0 - 2026-09-03
270
-
271
- Reported as "AI Unavailable, Error HTTP -400" on the Ask card. The AI was never
272
- asked: the tunnel was down, ngrok answered an HTML error page, and Connect IQ
273
- cannot parse HTML as JSON. Three surfaces had by now invented three vocabularies
274
- for the same class of failure, and this release collapses them into one — then
275
- adds the check that would have found it in a sentence.
276
-
277
- ### Fixed
278
-
279
- - **A request that never reached the server was reported as a broken AI.**
280
- `onPromptSubmitted` set the error to `"HTTP " + responseCode` under the heading
281
- *AI unavailable*, so `-400` which means the response body could not be parsed
282
- blamed the one component that had not been contacted. The pairing flow
283
- already classified this exact code correctly and the prompt flow never called
284
- it. `PairFail` is now `Fail` (`ciq/source/Fail.mc`) and pairing, the summary
285
- fetch and the prompt all route through it.
286
-
287
- - **A rotated API key was reported as an unreachable server.** Any summary
288
- failure drew "Could not reach TrainBud", including a 401 from a server that had
289
- answered perfectly well. A new `UNAUTHORIZED` class says *Watch not authorised
290
- pair this watch again*.
291
-
292
- - **A prompt poll that failed every time looked like one still running.**
293
- `onPromptStatusReceived` returned silently on any non-200, so the screen sat on
294
- "Asking AI..." for thirty seconds and then said "Timed out", naming nothing.
295
- One dropped Bluetooth response is now tolerated and a run of them is reported;
296
- a 401 is reported at once.
297
-
298
- - **No AI answer this app ever produced was readable.** The result was cut into
299
- 80-character substrings and each was handed to a single `drawText`, which does
300
- not wrap in Monkey C — so a page was laid out on one line, ran off both edges,
301
- and the cut fell mid-word. It survived every build, type check and store review
302
- because no Anthropic key had ever been configured on the machine it was written
303
- on, so the success path had never once been drawn. Answers now wrap to the
304
- chord width and page by line.
305
-
306
- - **`trainbud check` caused the failure it reported.** A rate-limited Garmin
307
- login was retried once per tool call, so one expired session became nine logins
308
- into a Cloudflare 429 in about five seconds. There is now a single cooldown,
309
- taken from the upstream's own retry-after and honoured before any login is
310
- attempted; a session already in hand keeps working.
311
-
312
- - **A test asserted against the developer's environment.** `checkAiStatus`
313
- cleared `ANTHROPIC_API_KEY` in a `before` hook, but `src/config.ts` calls dotenv
314
- at module load, so the dynamic import that followed put it straight back. Green
315
- for the life of the project purely because no key had ever existed on the
316
- machine it ran on; it failed the day one was added.
317
-
318
- ### Fixed — found by an adversarial sweep
319
-
320
- A ten-lens audit produced 52 candidates; each went to two independent verifiers
321
- told to refute it, and 42 survived. The ones fixed here:
322
-
323
- - **Every daily metric was fetched for the wrong day, west of UTC.**
324
- garmin-connect derives the calendar day from the Date it is given by
325
- subtracting `getTimezoneOffset()` — a function that round-trips a
326
- local-midnight Date and shifts a UTC-midnight one. Every Date this codebase
327
- produced was UTC midnight, so at UTC-5 the library asked Garmin about the 18th
328
- while the answer was stored under the 19th. Sleep, resting heart rate, stress
329
- and weight were all off by one day, silently, and every baseline and finding
330
- built on them described a day the user did not live. Invisible here because
331
- this machine is UTC+4. The suite now passes under Los Angeles, UTC, Baku and
332
- Auckland.
333
-
334
- - **Sleep debt was noise roughly half the time.** It summed one-sided shortfalls
335
- against the user's own median, and half of anyone's nights fall below their
336
- median by construction. Measured over 2000 synthetic steady sleepers with no
337
- deficit at all it fired on 46.7% of weeks at an hour of night-to-night
338
- variation. Now measured against a floor; false positives fall to 8.0% while a
339
- genuine 1.5 h/night deficit is still caught 93% of the time.
340
-
341
- - **An unworn night scored as a bad night.** Recovery read
342
- `sleep?.sleepTimeSeconds ?? 0`, scored zero hours as 35/100, and dragged an
343
- otherwise excellent day under the "fatigued" line.
344
-
345
- - **A gap in the store read as a run of days.** The recent window was the last N
346
- *points*, not N days.
347
-
348
- - **The request handler could not survive a throw.** One unguarded async
349
- callback, so a malformed `Host` header accepted by Node's parser, rejected by
350
- `new URL` — exited the process, with no credential required.
351
-
352
- - **`trainbud setup` destroyed the Anthropic key**, and a Garmin password
353
- containing `#` broke setup permanently, because values were written to `.env`
354
- unquoted.
355
-
356
- - **Every goal and injury was saved twice**; the dashboard bound its form twice.
357
-
358
- - **`trainbud check` caused the rate limit it reported**, and a backfill answered
359
- a 429 with ~1800 more requests.
360
-
361
- - **`app.db` held the Anthropic key at 0644**, and live pairing codes were
362
- written to the log.
363
-
364
- - **The glance drew a tofu box** for the "h" in "6.3h"; **an expired pairing code
365
- was polled forever**; **a rotated API key was hidden** behind ageing cached
366
- numbers; and **the Ask card accepted presses with no AI key**.
367
-
368
- - **The only type check CI ran never looked at a test file**, which is why four
369
- stale fixtures compiled.
370
-
371
- ### Added
372
-
373
- - **`trainbud doctor`, `GET /api/selftest`, and a Connection panel on the
374
- dashboard.** Fetches the configured public URL from outside this machine with
375
- the watch's own headers and grades the answer with the watch's taxonomy. Status
376
- alone is not the test both of this project's expensive network bugs were a
377
- 200 or a 404 carrying HTML so the body is graded and a 200 of HTML fails.
378
-
379
- - **A Week card, and `get_week_review`.** This week against last, per metric,
380
- split by date rather than by position so a missing day cannot shift the
381
- boundary. A metric absent on either side reads as unknown rather than as a
382
- delta against zero.
383
-
384
- - **A load forecast.** Where the acute:chronic ratio lands if next week repeats
385
- this one. The existing detector fires after the jump; this fires before it. The
386
- projection slides the whole 28-day window forward rather than only advancing
387
- the acute end.
388
-
389
- - **Sleep debt and consistency**, against the user's own median night rather than
390
- eight hours, with consistency as median absolute deviation so one recovery
391
- sleep cannot make a metronomic sleeper look erratic. Surfaced under last
392
- night's hours as "Usually 7.2h · variable".
393
-
394
- - **A race countdown.** The context store has held races since the memory layer
395
- landed and nothing ever read one for its date, though it changes what every
396
- other number means a falling load ratio is a warning in January and the plan
397
- in a taper. `activeContext` excludes future dates by design, so
398
- `upcomingContext` was added.
399
-
400
- - **`build.ps1 -Screens` and `scripts/capture-screens.ps1`.** Every screen the
401
- app can draw, with no server, photographed on any device in one command. 1.3.0
402
- shipped to the store having never been drawn once. Three faults it caught
403
- immediately: the default jungle `sourcePath` was pulling the debug driver into
404
- the store build; a jungle setting is assigned rather than appended, so listing
405
- two jungles silently un-excluded the glance; and the Forerunner 55's recovery
406
- card collided with its own ring.
407
-
408
- ## [watch 1.3.1] - 2026-09-02
409
-
410
- Nobody who installed the watch app from the store could ever pair it. Reported
411
- from a Forerunner 55 on firmware 11.03; it affected every user on every device.
412
-
413
- ### Fixed watch
414
-
415
- - **The store build shipped a default Server URL pointing at a developer's
416
- personal ngrok tunnel.** From 1.2.0 onward `properties.xml` carried
417
- `https://backpedal-immorally-cathouse.ngrok-free.dev` as the default, so a
418
- fresh install found a non-empty URL, skipped the setup screen entirely, POSTed
419
- `/api/pair` at a host that was usually offline, and rendered "Pairing failed"
420
- forever. The tunnel answers `ERR_NGROK_3200` when it is not running; the watch
421
- read the HTML error page as `-400` and reported a pairing failure. The default
422
- is now empty, which routes a fresh install to a **Setup required** screen
423
- naming the setup guide. Sideloading keeps a baked URL through the new
424
- `ciq/monkey-dev.jungle` overlay, which is gitignored and never in a store
425
- build.
426
-
427
- The same default was a privacy hazard in the other direction: while that
428
- tunnel was up, a stranger's watch minted a pairing code against the
429
- developer's own health server, one approval click away from handing out a
430
- bearer token to somebody else's Garmin data.
431
-
432
- - **One "Pairing failed" screen became three, each naming what the user can
433
- fix.** *Cannot reach server* (nothing answered), *Not a TrainBud server*
434
- (something answered and it was not us a dead tunnel, a captive portal, the
435
- wrong address), *Server refused pairing*. The address in use is drawn on
436
- screen whenever the address is the suspect, and `-104`, `-1001` and `429` get
437
- their own one-line hints. `-1001` distinguishes a plain `http://` URL from an
438
- `https://` one whose certificate was refused: the same code, two different
439
- problems, and telling a self-hosted user to "use https" when they already do
440
- is a dead end.
441
-
442
- - **Round screens were detected by measuring pixels rather than asking the
443
- device.** `isRoundScreen()` was `width == height && width >= 240`, so the
444
- Forerunner 55 round, 208×208 — was treated as rectangular and drew the
445
- recovery bar instead of the ring. Now `System.getDeviceSettings().screenShape`.
446
-
447
- - **Text wrapped to a fixed character count, which a circle does not honour.**
448
- A line near the top of a round screen has far less room than one through the
449
- middle; "Cannot reach server" rendered as "annot reach serve". Wrapping is now
450
- measured in pixels against the chord width available at that height.
451
-
452
- - **The five button-only products could not navigate.** `BehaviorDelegate` maps
453
- UP and DOWN to `onNextPage`/`onPreviousPage` and neither was implemented, so
454
- on fr55, fr745 and the three Instinct 3 variants the carousel only moved
455
- forwards one START press at a time and the Ask menu could not be scrolled at
456
- all. Every on-screen hint also said "tap"; hints are now chosen from
457
- `System.getDeviceSettings().isTouchScreen`.
458
-
459
- - **Grey was invisible on the Forerunner 55.** Its palette holds eight colours
460
- and none of them is grey, so `COLOR_DK_GRAY` snapped to black on a black
461
- background: inactive page dots, faded Ask items, card footnotes and the ring
462
- track all vanished. Secondary elements use a colour that survives the palette,
463
- and inactive dots are outlined rather than filled, so the hierarchy is carried
464
- by shape.
465
-
466
- - **Pairing telemetry is debug-only.** The pairing screen drew `9/8 200` in the
467
- corner of a store build unexplainable to a user, and the difference between
468
- a poll that never ran and one discarded on the device to anyone debugging.
469
-
470
- - **The AI disclaimer is now on screen.** The string existed from 1.2.0 and was
471
- never drawn anywhere, so the app made training and recovery statements on a
472
- health device with nothing to qualify them. It renders on the last page of an
473
- answer.
474
-
475
- ### Fixed — watch, found by drawing it
476
-
477
- Everything below was found by running 1.3.1 in the simulator on a Forerunner 55
478
- and a fenix 8 47mm and looking at every card. None of it was visible from the
479
- source, the type checker or a green build, and none of these screens had ever
480
- been drawn on any watch: 1.3.0 shipped its `.iq` without a single render.
481
-
482
- - **The Today card printed its first finding through its own title,** and cut a
483
- character off each end of it: "Resting HR 4 bpm above" drew as "esting HR 4 bpm
484
- above". It centred the text block on the screen rather than in the space below
485
- the heading, and wrapped to a fixed 24 characters. This is card 0 — the first
486
- thing a paired user sees.
487
-
488
- - **The Sleep card drew an empty yellow box next to "6.3".** The FONT_NUMBER_*
489
- faces contain digits and separators and no letters, so the "h" rendered as a
490
- missing-glyph box and a box has a width, so the "does this fit" check passed
491
- and the value never stepped down to a font that has letters. The Recovery
492
- card's "No data" had the same fault waiting.
493
-
494
- - **The Ask AI card printed the selected prompt underneath its own hint:**
495
- "Why is my re[STA]sting HR up?". The hint was right-justified against the
496
- screen edge at the vertical centre, which on a circle is exactly where the
497
- widest line already is.
498
-
499
- - **The carousel dead-ended on the Ask card.** Going back from the first prompt
500
- left the card, but going forward from the last one wrapped around, so the six
501
- metric cards after it could only be reached by paging *backwards* from Today.
502
- It now leaves at both ends, on buttons and on swipe.
503
-
504
- - **The AI Insight card overlapped its own lines on large screens.** The line
505
- step was a hardcoded 20 px, which is about right for the 208 px Forerunner 55
506
- and much too small for a 454 px fenix. Every hardcoded line step is now
507
- derived from the font.
508
-
509
- - **The Recovery card printed "Ready" through the bottom of the score,** for the
510
- same reason: the label sat at a fixed offset from the centre while the score
511
- is drawn in a font whose height nearly doubles between those two devices. The
512
- label and the heart rate line are now stacked off the score's measured height.
513
-
514
- - **"Resting 48 Max 178" lost its last digits** at the bottom of a 208 px round
515
- screen. It now shortens the *label* before the number: "Rest 48 Max 178".
516
-
517
- - Activity card: the workout name was cut to 14 characters before being drawn,
518
- which threw away room a smaller font would have used. `drawFittedValue`
519
- already measures and steps down; the character cut is gone. This was the last
520
- item open on that card.
521
-
522
- ### Fixed "AI unavailable" meant four different things
523
-
524
- Reported as "it still shows AI Unavailable". The cause on this install was that
525
- **no Anthropic key had ever been configured**: the settings table was empty,
526
- `.env` had no `ANTHROPIC_API_KEY`, and the only two prompt jobs ever created, on
527
- 2026-08-17 and 2026-08-19, both failed with
528
- `ANTHROPIC_API_KEY not configured`. AI is bring-your-own-key and genuinely could
529
- not run. The defect is that the watch could not say so.
530
-
531
- - **`/api/watch` now carries `ai_configured`.** `ai_insight: null` meant three
532
- different things no key, a failed call, or today's insight not generated
533
- yet — and the watch drew one screen for all of them. The Ask card now shows
534
- **"AI not set up / Add an API key in the dashboard"** instead of offering five
535
- questions that cannot succeed, and the AI Insight card no longer tells a user
536
- with no key that there is "No insight today", which is a claim about today. A
537
- summary from an older server carries no such field; missing is read as
538
- configured, so nothing is asserted on no evidence.
539
-
540
- - **The watch threw away the reason a prompt failed.** `GET /api/prompt/<id>`
541
- returns the server's error string, and `onPromptStatusReceived` read only
542
- `status` so a missing API key and a provider outage rendered identically.
543
- The reason is now drawn under the message, along with a timeout and the HTTP
544
- code from a failed submit.
545
-
546
- - **`trainbud check` reported AI status from the environment only.** It read
547
- `appConfig.anthropicApiKey` instead of `isAiConfigured()`, so a key saved
548
- through the dashboard the route the setup guide tells users to take — was
549
- reported as absent forever. `resolveAnthropicKey()` exists for exactly this
550
- and this call site never used it.
551
-
552
- ### Fixed dependencies
553
-
554
- - `qs` 6.15.3 6.16.0 and `fast-uri` 3.1.5 3.1.7, clearing one high and one
555
- moderate advisory. `npm audit` is clean.
556
-
557
- ### Removed
558
-
559
- - Dead resources and code: `drawHint()` and the `TapHint` string it drew
560
- (replaced by page dots in 1.2.0), plus the unused `AskHint`, `PairingPolling`
561
- and `CardHeartRate` strings.
562
-
563
- ## [0.3.1] - 2026-08-19
564
-
565
- Watch pairing works. It never had, on any build, and the cause was the last one
566
- still open: a status poll that never reached the server.
567
-
568
- ### Fixed watch
569
-
570
- - **Pairing completes end to end.** ngrok's free tier answers any GET carrying a
571
- browser-ish User-Agent with an HTML interstitial under a 200. Connect IQ sends
572
- `Mozilla/5.0` and will not let an app override it, so every status poll was
573
- answered by the tunnel, never reached the server, and failed on the watch as
574
- `-400 INVALID_HTTP_BODY_IN_NETWORK_RESPONSE`. POSTs are not intercepted, which
575
- is why `/api/pair` always worked and the poll never did. Verified in the
576
- simulator against the live tunnel: code issued, approved in the dashboard,
577
- credentials saved, summary fetched.
578
- - Recovery ring drew the inverse of the score: the end angle is measured
579
- clockwise but the arc was drawn counter-clockwise, so 91 rendered as the
580
- missing 9%.
581
- - Recovery ring overlapped the card title.
582
- - Fractional values drew with six decimal places (`6.300000h`), on both the
583
- widget and the glance.
584
- - The four-cell overview grid used "No data" as its placeholder, which drew over
585
- the neighbouring cells and their labels; it now uses a dash.
586
- - Activity card: subtitle and footnote collided, and a strength workout listed
587
- "0 km" as though zero were a measurement.
588
-
589
- ### Fixed server
590
-
591
- - **Recovery score was NaN on every default call** and reached the watch as
592
- `null`: normalizeWeights spread an object of explicit `undefined` weights over
593
- its defaults, and `NaN <= 0` is false so the guard missed it.
594
- - **Stress and VO2 max were fetched from URLs Connect answers 404 for.** Both
595
- take the date as a path segment; VO2 max is `maxmet/latest`, not
596
- `maxmet/daily`. The stress mapper also read fields the response does not
597
- contain (`overallStressLevel` rather than `avgStressLevel`), and Connect's
598
- negative "not measured" sentinels were averaged in as real readings.
599
- - Pair codes came from `Math.random()`. `/api/pair` is unauthenticated by
600
- design, so an attacker could mint codes, recover the PRNG state, predict the
601
- code the watch was showing and collect the API key on approval. Now
602
- `crypto.randomInt`.
603
- - Rate limiting only ever ran on `/mcp`, leaving the unauthenticated pair
604
- endpoints open to a walk through the six-digit code space; and buckets were
605
- keyed on the socket address, which behind a tunnel is one bucket for every
606
- client. Limits now cover every route, with a tighter budget for pairing, keyed
607
- on the forwarded address.
608
- - API key compared in constant time.
609
- - `.env`, `session.json` and the MCP client config were written world-readable
610
- (0644) and are now 0600.
611
- - A single failed Garmin login was memoised and poisoned every later call until
612
- the process restarted.
613
- - All four npm audit advisories cleared.
614
-
615
- ### Added
616
-
617
- - Real Connect IQ Store screenshots, captured from a paired app in the
618
- simulator (`ciq/store/screenshots/`). The previous set was drawn in PowerShell
619
- and removed in 1.2.0.
620
- - Tests: pair code randomness, pair rate limiting, secret file permissions,
621
- client auth lifecycle, the real Connect stress payload, recovery weights.
622
- 91 tests, up from 73.
623
-
624
- ## [0.2.0] - 2026-06-26
625
-
626
- ### Fixed
627
-
628
- - **Critical:** Logger no longer writes to stdout pino-pretty uses stderr; file logging starts only when server starts
629
- - **Critical:** Credentials validated at server startup, not on first tool call
630
- - N+1 Garmin API calls batched with concurrency limit (`mapInBatches`, max 6 parallel)
631
- - Body composition fetches parallelized instead of sequential
632
- - Activities range queries use shared paginated pool cache (up to 500 activities) with truncation warning
633
- - Cache keys unified via `buildToolCacheKey()` with stable sorted-param hashing
634
- - Recovery tool uses yesterday's sleep data with fallback to prior nights
635
- - Activity date filtering uses consistent Luxon parsing
636
- - Session path resolved from single `getSessionPath()` in config
637
- - Version read from `package.json` instead of hardcoded strings
638
- - `GarminApiError` is now a proper class; auth retry uses `await`
639
- - Tool errors sanitized before returning to MCP clients
640
- - SQLite cache closed on SIGTERM/SIGINT/exit
641
-
642
- ### Added
643
-
644
- - `src/version.ts`, `src/utils/batch.ts`, `src/garmin/garminApiTypes.ts`, `src/tools/types.ts`
645
- - `filterActivitiesByRange()`, `sanitizeErrorMessage()`, `getYesterday()` helpers
646
- - `configureLogger()` for lazy log file initialization
647
- - `.nvmrc` (Node 20)
648
- - `.github/workflows/publish.yml` for npm + GitHub Releases on version tags
649
- - Project knowledge base moved to Obsidian vault (`05-Projects/trainbud/`); see `docs/VAULT.md`
650
- - 11 new tests (32 total): cache key stability, date filtering, recovery scoring, error sanitization
651
-
652
- ### Changed
653
-
654
- - **Rebranded** from garmin-mcp to **TrainBud** (package `trainbud`, CLI `trainbud`)
655
- - README rewritten as product page with disclaimer, badges, and security section
656
- - Added CONTRIBUTING.md; updated vault docs and examples
657
- - Removed imports from internal `garmin-connect/dist/` paths
658
- - Tool registry uses shared `ToolDefinition` interface without unsafe casts
659
- - `runCacheClear` is synchronous
660
-
661
- ## [0.1.0] - 2026-06-26
662
-
663
- ### Added
664
-
665
- - Initial MCP server exposing 6 Garmin Connect tools
666
- - Email/password authentication with session persistence (`.trainbud/session.json`)
667
- - SQLite caching layer with configurable TTL per resource type
668
- - CLI commands: `start`, `auth`, `cache clear`, `status`
669
- - Unit and integration tests using Node test runner
670
- - README, QUICKSTART, and example prompts
671
-
672
- ### Notes
673
-
674
- - Uses unofficial `garmin-connect` npm package (Windows/macOS/Linux compatible)
675
- - MFA is not yet supported by the underlying library
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ ## [0.5.2] — server 0.5.2 · watch 2.0.2 — 2026-09-06
6
+
7
+ ### Addeda paired watch no longer holds the master key
8
+
9
+ - **Pairing mints a token scoped to that device.** `/api/pair/<code>/status` used
10
+ to hand the watch `TRAINBUD_API_KEY` itself: the credential that also opens the
11
+ dashboard and `/mcp`, sent to a device over a public tunnel, with no way to take
12
+ it back except rotating the key for everything at once. It now mints a
13
+ 256-bit token per pairing and stores only its SHA-256, so a copy of `app.db` is
14
+ not a working watch credential.
15
+ - **`trainbud devices`** lists paired watches with when they were paired and last
16
+ seen; **`trainbud devices revoke <id>`** (or `--all`) takes one away without
17
+ logging out the dashboard, the MCP endpoint, or the other watches. Revoking is
18
+ now the documented way to remove a watch rotating `TRAINBUD_API_KEY` is not.
19
+ - **Nothing on the watch changed and no re-pair is forced.** The field name in the
20
+ pairing response is unchanged, the master key is still accepted, and a watch
21
+ paired before this release keeps working. Re-pair it to swap its stored master
22
+ key for a scoped token.
23
+
24
+ ### Added security headers, on every response including the 401s
25
+
26
+ - The server shipped 0.5.1 with none, while being reachable from the internet for
27
+ as long as the watch's tunnel is up. Now sends `Content-Security-Policy`
28
+ (`frame-ancestors 'none'`, `form-action 'self'`, no external script or style),
29
+ `X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy: no-referrer`, and
30
+ the two `Cross-Origin-*` policies. Applied before routing, so an error path
31
+ cannot miss them.
32
+ - `script-src` keeps `'unsafe-inline'`: the dashboard is server-rendered HTML with
33
+ inline handlers, and a nonce policy is a rewrite of the page, not a header.
34
+ - **HSTS only when the request actually arrived over TLS.** Pinning https on a
35
+ host the user reaches at `http://127.0.0.1:3847` would lock them out of their
36
+ own dashboard.
37
+
38
+ ### Changed
39
+
40
+ - npm keywords: added `mcp-server`, `cursor`, `chatgpt`, `connect-iq`.
41
+ - 569 tests (567 pass, 2 skipped), up from 551.
42
+
43
+ ## [0.5.1] — server 0.5.1 · watch 2.0.2 — 2026-09-06
44
+
45
+ First release published to npm: `npx trainbud setup`. Trusted publishing over
46
+ OIDC, no stored token see `docs/RELEASING.md`.
47
+
48
+ ### Fixed the published package was 42% dead weight
49
+
50
+ - **134 of 274 files in the tarball were source maps that resolved to nothing.**
51
+ `.js.map` and `.d.ts.map` both point at `../src/*.ts`, and `files` does not
52
+ ship `src`, so every map in the published package referenced a file that was
53
+ not there. Excluded with `!dist/**/*.map`; maps are still generated locally.
54
+ **274 files 140, 1.0 MB 644 kB unpacked.**
55
+
56
+ ### Fixeddocs that told users the package did not exist
57
+
58
+ - `QUICKSTART.md` led with `git clone` and stated TrainBud was not on the npm
59
+ registry. Both Claude Code setup skills said the same, one of them in the very
60
+ step that installs the CLI. All now lead with `npx trainbud setup`, with the
61
+ clone path kept for running an unreleased commit.
62
+ - README gains an npm version badge.
63
+
64
+
65
+ ### Fixed a rotated key left the watch with no way back
66
+
67
+ - **A 401 cleared nothing, so the watch could never pair again.** The summary
68
+ fetch classified the failure correctly and drew "Watch not authorised / Pair
69
+ this watch again", then kept the dead key in `Application.Storage`. Pressing
70
+ the button called `fetchSummary()`, which only starts pairing when the key is
71
+ *empty*, so every press re-sent the same dead key and got the same 401. The
72
+ instruction on screen was not something the watch could actually do, and
73
+ reinstalling the app was the only cure for a state the security docs
74
+ actively tell users to create, since rotating `TRAINBUD_API_KEY` is how you
75
+ revoke a paired watch. A 401 now drops the stored key, and the next press
76
+ requests a fresh pairing code. The server URL is kept: it is still right, and
77
+ nobody should retype an address on a watch.
78
+ - Status deliberately stays `error` rather than `pairing_error`: that screen
79
+ reads the *pairing* fail class, which is unset on a summary 401, so it would
80
+ have drawn "Could not reach TrainBud" about a server that answered.
81
+
82
+ ### Note on versions
83
+
84
+ **The store served 2.0.1 and this repository could not build it** — no tag, no
85
+ manifest bump, no changelog entry. This release is **2.0.2**, built from `main`,
86
+ so the shipped artifact and the source agree again.
87
+
88
+
89
+ ### Security — the key stopped living in the address bar
90
+
91
+ - **The dashboard trades `?token=` for a session cookie and redirects to a clean
92
+ URL.** The API key opened the dashboard and then stayed in the address bar for
93
+ the whole session, which put it in browser history, in the tunnel provider's
94
+ access log, and in every screenshot of the page and this page is about to be
95
+ screenshotted by people announcing it. The token still gets you in; what carries
96
+ you afterwards is an opaque 32-byte session id held in the server's memory, so it
97
+ grants nothing after a restart and cannot be replayed against another server.
98
+ `HttpOnly` keeps page script off it and `SameSite=Lax` is what stops cookie auth
99
+ from adding the CSRF that a Bearer header never had. `Secure` goes on only when
100
+ the request arrived over TLS, because a browser silently discards a Secure cookie
101
+ on `http://127.0.0.1` which is how the local dashboard is reached.
102
+ A Bearer header still works untouched, and is what the watch and MCP clients use.
103
+ - **The paired watch holds the API key itself, and the docs now say so.** Approving
104
+ a pairing hands the watch the same credential that opens the dashboard and `/mcp`,
105
+ not a narrower per-device token. The privacy policy implied a scoped token; it now
106
+ states what is actually handed over and how to revoke it. A per-device token is
107
+ still owed.
108
+ - **`scripts/start-watch-stack.ps1` no longer defaults to the maintainer's own ngrok
109
+ domain.** Anyone who cloned the repo and ran the script published a tunnel pointed
110
+ at somebody else's address. It reads `TRAINBUD_NGROK_DOMAIN` and refuses to guess.
111
+
112
+ ### Changed the README a launch link lands on
113
+
114
+ - Quick start leads with `npx trainbud setup`; the clone-and-link path moves into a
115
+ "from source" fold. The old first instruction was four commands and a caveat.
116
+ - A **"What TrainBud is not"** section: no hosted service, not an official Garmin
117
+ integration, not MFA-compatible, and the AI features bill to your own key.
118
+ - The test count said 33. It is 551.
119
+
120
+ TrainBud knew a great deal about your body and nothing about you. This release
121
+ adds the half that was missing, and takes four decisions off the watch that it
122
+ was never in a position to make.
123
+
124
+ ### Added — a profile, and a product that knows who it is talking to
125
+
126
+ - **`src/profile.ts`** name, units, primary sport, weekly goal, the bands at
127
+ which a number turns amber or red, the watch card order, and the AI's voice.
128
+ One row of `app.db`, read by every surface. Reads salvage field by field
129
+ (a settings row that is not JSON is wreckage, not configuration, and
130
+ `getProfile` sits on the path of every watch fetch); writes are strict and
131
+ name the field they refused.
132
+ - **Personal thresholds decide colour everywhere.** Resting heart rate is graded
133
+ on the distance from *your own* median rather than on the rate — 58 bpm is
134
+ unremarkable for one person and a warning for another.
135
+ - **Card order and visibility** are set in the dashboard and live on the watch's
136
+ next fetch. No Connect IQ settings sync, no store update.
137
+
138
+ ### Added what the AI costs, which nothing had ever counted
139
+
140
+ - Every AI call records its tokens and its price. Month-to-date spend is a
141
+ **calendar month**, not a rolling thirty days: a cap on a sliding window never
142
+ resets, and the number is compared against a bill that runs in months.
143
+ - An optional monthly cap **refuses the request before the money is spent**, and
144
+ says so on the watch instead of after a round trip. No cap is set by default,
145
+ so nothing is ever blocked out of the box.
146
+ - **An unknown price is null, never zero.** A model this build has no rate for
147
+ records its tokens and leaves the cost unknown, because a call priced at zero
148
+ is a cap that can never trip. Every total carries the count of calls it could
149
+ not price and says when it is a floor rather than a total.
150
+
151
+ ### Added the Ask menu you write yourself
152
+
153
+ Up to five questions, set in the dashboard, that lead the Ask menu on the wrist in
154
+ the order you put them — ahead of the generated ones, in every state, including the
155
+ first fortnight when the app has nothing of its own worth asking about. Whatever
156
+ slots are left still fill from what actually fired, so "Why is my resting HR up?"
157
+ keeps its place on the day it is up.
158
+
159
+ **`ai.customPrompts` had existed since the profile landed and nothing read it.** It
160
+ was in the schema, it validated, it saved and no dashboard control wrote it and no
161
+ code path consumed it, so a question typed against that API could never have appeared
162
+ on a watch. The bounds it shipped with said as much: eight questions for a five-slot
163
+ menu, 120 characters for a line the watch draws at 32. Both now come from
164
+ `promptSuggestions.ts`, which owns the menu, and the profile refuses to store a
165
+ question that could not be shown.
166
+
167
+ ### Added — a training dashboard
168
+
169
+ - Phone first, because the pairing flow already was. Findings, the week against
170
+ last week, resting heart rate and sleep against your own median, spend, and
171
+ every setting above.
172
+ - Drawn from local SQLite and nothing else no Garmin round trip, so it paints
173
+ instantly and works with an expired session. Charts are inline SVG built by
174
+ pure functions; there is no CDN, because a script tag would tell a third party
175
+ every time you opened your own health dashboard.
176
+ - **A missing day is a gap, not a zero.** Lines break at an absence, days are
177
+ placed by date rather than by array position, and a chart with nothing to draw
178
+ says so instead of rendering an empty axis that reads as "zero, every day".
179
+
180
+ ### Addedlocal feature counters
181
+
182
+ Which of the nine cards anyone opens, and how often the Ask path runs. Counted
183
+ on a request the watch was already making rather than per swipe. On by default
184
+ because there is no endpoint to send them to; switchable off, with a delete
185
+ button, in the dashboard under Privacy.
186
+
187
+ ### Changed the watch draws state, it no longer decides it
188
+
189
+ `recoveryColor`, `sleepColor`, `stressColor` and `heartRateColor` are gone from
190
+ the Monkey C. The server grades; the watch colours. That removed three defects
191
+ rather than moving code: the Overview grid graded two of its four cells and
192
+ recovered the number by re-parsing its own formatted string (`parseNumber("6.3h")`
193
+ returns 6); `heartRateColor` painted one line holding both the resting and the
194
+ maximum heart rate while grading only the resting one; and a missing value fell
195
+ through to white, which is also what "graded and unremarkable" looks like.
196
+
197
+ ### Fixed on the wrist
198
+
199
+ - A finding that does not fit is **counted, not dropped**. The same payload
200
+ showed two findings on a Fenix and one, silently, on a Forerunner 55.
201
+ - The recovery ring no longer strikes through the heart-rate line beneath it.
202
+ A circle narrows as it descends; the label had been tested against that since
203
+ 1.3.2 and the line under it never was.
204
+ - Severity is a marker, not the text colour. A warning arrived as five lines of
205
+ red on black the least legible combination there is on a transflective
206
+ screen in daylight.
207
+ - A big **fall** in training load is marked. Only a rise was, so a 40% collapse
208
+ — the shape of an illness week passed without a mark.
209
+ - Colours are chosen against the worst screen in the manifest. The Forerunner 55
210
+ has an eight-colour palette and rounds everything to the nearest entry, which
211
+ is why "good" is `#4CD964`: the mint the dashboard shipped with rounds to CYAN
212
+ there.
213
+
214
+ ### Fixed verification
215
+
216
+ The Forerunner 55 could not finish the screen tour. It died partway through,
217
+ twice, reported as "keypress did not register" — which reads exactly like the
218
+ simulator being flaky, and was not: the tour allocated a second full payload
219
+ while the first was still live, and on that device it is the difference between
220
+ fitting and not. All 28 states now capture on fr55 and fenix847mm.
221
+
222
+ ### Fixed store screenshots the app actually drew
223
+
224
+ The Connect IQ listing still carried five 1.3.x captures: an Overview grid from
225
+ before all four cells were graded, and findings as coloured body text rather
226
+ than white text with a severity marker. Replacing them had already failed twice
227
+ in one day once because the screen tour paints its state counter over the app
228
+ ("9/28" above the title, in five images that reached the tree), once because the
229
+ crop was measured inwards from the simulator window instead of from the device,
230
+ so it sat off-centre and carried the watch case, and the vendor's wordmark
231
+ printed on it, into a picture meant to be the screen.
232
+
233
+ - **`build.ps1 -Screens -NoLabel`** compiles the counter out through a
234
+ `ScreenTour.labelVisible()` annotation pair, so no keypress can bring it back.
235
+ - **`capture-sim.ps1 -Display -Device <id>`** locates the device artwork in the
236
+ window and adds `display.location` from the SDK's own `simulator.json`,
237
+ writing the display at native resolution — 390×390 on fr70 — with the corners
238
+ a round screen cannot physically show blacked out. If the artwork is not
239
+ there at 1:1 it refuses, rather than cropping something plausible.
240
+ - **`capture-store-shots.ps1`** drives the tour, refuses any build but the
241
+ label-free one, re-checks every saved image for the counter's colour, and
242
+ stops the run when a keypress does not land instead of naming every remaining
243
+ file after the state before it.
244
+
245
+ `ciq/store/screenshots/store/` is now Today, Week, Recovery, Overview and Ask as
246
+ 2.0.0 draws them. The five whole-watch 558x558 images that sat beside them are
247
+ gone: nothing referenced them, they were 1.3.x too, and they were pictures of a
248
+ watch rather than of a screen.
249
+
250
+ ---
251
+
252
+ ## [0.4.1] — server 0.4.1 · watch 1.4.0
253
+
254
+ "The AI says it has no access to my data" was reported once and fixed once, in
255
+ `bb1fae8`. This release is what an audit found behind it: **four more distinct
256
+ causes of that same sentence**, none of which the first fix touched, plus six
257
+ defects the sweep turned up alongside them. Every one was verified against this
258
+ machine's real install, whose Garmin session is genuinely gone and whose record
259
+ genuinely stops on 2026-08-21 which turned out to be the most valuable test
260
+ fixture in the project.
261
+
262
+ ### Fixed — the AI could not see the user's data, four more ways
263
+
264
+ - **The one tool that reads the store told the model the store was empty.**
265
+ `get_findings` and `trainbud findings`, on a store holding 74 days:
266
+ *"Still gathering data 74 of the 14 days needed."* `coverage.ready` carries
267
+ two refusals that need opposite answers — not enough history yet, and plenty
268
+ of history that stops weeks ago — and `bb1fae8` taught exactly one of the four
269
+ surfaces to tell them apart. `FindingsPayload.coverage` was also typed
270
+ `{ days, ready }` while the value assigned to it carried four fields, so the
271
+ renderer could not have read `throughDate` had it wanted to. One
272
+ `describeFindingsCoverage`, read by all four surfaces.
273
+
274
+ - **Nothing on the read path had ever opened the history store.** Every
275
+ per-metric tool read Garmin and nothing else, so an expired session meant
276
+ `get_sleep_data` threw, all six of the watch summary's payload calls returned
277
+ null, and an MCP client got a tool error — with 598 measurements and 2139
278
+ archived Connect responses on disk. Tools now fall back to `raw_payload`,
279
+ re-run through the *same* mapper the live path uses so a stored night keeps
280
+ its stage breakdown, and to `daily_metric` behind it for days older than the
281
+ 180-day archive. **The window moves too**: with the record ending 08-21, "the
282
+ last 7 nights" selects seven days that were never recorded, so serving the
283
+ store changed nothing until the window could slide to where the data is and
284
+ say so, in capitals, so no model reads a fortnight-old week as this week.
285
+
286
+ - **A dependency printed English into the middle of the MCP protocol stream.**
287
+ `garmin-connect` calls `console.log('login page title:', …)` on every
288
+ re-login, and `console.log` writes to stdout, which is the JSON-RPC channel
289
+ for `trainbud start`. Claude Desktop and Cursor the two clients `setup`
290
+ configures lose the session on any re-login inside a tool call. Stdout is
291
+ now claimed for the life of the process rather than one more `console` method
292
+ being patched at one more call site.
293
+
294
+ - **Any CLI command killed the answer the watch was waiting for.**
295
+ `reconcilePromptJobs` ran inside `getDb()`, so the first time *any* process
296
+ opened `app.db` it flipped every in-flight job to *"The server stopped before
297
+ this answer came back."* Running `trainbud doctor` while the watch waited did
298
+ exactly that. It now runs once, from `serve`, and never touches a job younger
299
+ than five minutes.
300
+
301
+ ### Fixed numbers the app stated and had not measured
302
+
303
+ - **"This week against last week" compared seven days against eight.**
304
+ `series(kind, n)` spans `n + 1` days inclusive, so `WEEK_DAYS * 2` split at
305
+ `today - 7` gave halves of 7 and 8. The TRIMP load line beside it summed two
306
+ correct seven-day windows, so a single card disagreed with itself.
307
+
308
+ - **The sleep card called a fortnight-old night "last night".**
309
+ `hours.slice(-7)` is the last seven *rows*, which are seven nights only if the
310
+ watch was worn every night. Windowed by date now, like `detectors.ts` and
311
+ `week.ts` already were; a week with nothing in it reports no debt rather than
312
+ a debt of zero.
313
+
314
+ - **`status` reported 1590 empty days for a store covering 366 dates.** The
315
+ count was over `ingest_day` rows, which are keyed (date, source) across six
316
+ sources. Now 292 empty days against 74 measured figures that add up.
317
+
318
+ ### Fixed — security and data loss
319
+
320
+ - **The crash handler wrote the API key to the log the request logger had
321
+ stopped writing.** The dashboard authenticates with `?token=`, and
322
+ `logger.error({ error, url: req.url })` was raw. The occurrence already
323
+ present in `.trainbud/mcp.log` has been scrubbed. **Rotate the key**: a
324
+ credential that has sat in a world-readable file is exposed whatever the file
325
+ says now.
326
+
327
+ - **Only `app.db` was hardened to 0600.** `history.db` a year of sleep, heart
328
+ rate and HRV `cache.db` and the log file were all at the default 0644.
329
+
330
+ - **`setup` rewrote the user's Claude Desktop config keeping only our own key.**
331
+ `readMcpConfig` returned `{ mcpServers }` and the write dropped everything
332
+ else; a config with no `mcpServers` key at all was replaced wholesale. The
333
+ file is now read whole and copied to `<name>.bak.json` before being touched.
334
+
335
+ - **The retry path erased the rate-limit block it had just walked into.**
336
+ `withGarminClient` called `resetGarminClient`, which clears the persisted
337
+ cooldown, and then forced a login into the live block on the single path
338
+ that block exists to guard. Splitting "forget the session" from "forget the
339
+ limit" also lets the backoff ladder escalate, which it never could.
340
+
341
+ - **The cooldown test wrote a live five-minute Garmin block into the
342
+ developer's own database.** Found by attempting a backfill after the suite
343
+ passed. Tests no longer touch the real `app.db`.
344
+
345
+ ### Notes
346
+
347
+ Two regression tests in this release passed against the deliberately broken
348
+ build before being rebuilt. Reverting the fix remains the only thing that
349
+ separates a regression test from a decoration.
350
+
351
+ ## [0.4.0] — server 0.4.0 · watch 1.4.0 - 2026-09-03
352
+
353
+ Reported as "AI Unavailable, Error HTTP -400" on the Ask card. The AI was never
354
+ asked: the tunnel was down, ngrok answered an HTML error page, and Connect IQ
355
+ cannot parse HTML as JSON. Three surfaces had by now invented three vocabularies
356
+ for the same class of failure, and this release collapses them into one — then
357
+ adds the check that would have found it in a sentence.
358
+
359
+ ### Fixed
360
+
361
+ - **A request that never reached the server was reported as a broken AI.**
362
+ `onPromptSubmitted` set the error to `"HTTP " + responseCode` under the heading
363
+ *AI unavailable*, so `-400` — which means the response body could not be parsed
364
+ blamed the one component that had not been contacted. The pairing flow
365
+ already classified this exact code correctly and the prompt flow never called
366
+ it. `PairFail` is now `Fail` (`ciq/source/Fail.mc`) and pairing, the summary
367
+ fetch and the prompt all route through it.
368
+
369
+ - **A rotated API key was reported as an unreachable server.** Any summary
370
+ failure drew "Could not reach TrainBud", including a 401 from a server that had
371
+ answered perfectly well. A new `UNAUTHORIZED` class says *Watch not authorised
372
+ — pair this watch again*.
373
+
374
+ - **A prompt poll that failed every time looked like one still running.**
375
+ `onPromptStatusReceived` returned silently on any non-200, so the screen sat on
376
+ "Asking AI..." for thirty seconds and then said "Timed out", naming nothing.
377
+ One dropped Bluetooth response is now tolerated and a run of them is reported;
378
+ a 401 is reported at once.
379
+
380
+ - **No AI answer this app ever produced was readable.** The result was cut into
381
+ 80-character substrings and each was handed to a single `drawText`, which does
382
+ not wrap in Monkey C — so a page was laid out on one line, ran off both edges,
383
+ and the cut fell mid-word. It survived every build, type check and store review
384
+ because no Anthropic key had ever been configured on the machine it was written
385
+ on, so the success path had never once been drawn. Answers now wrap to the
386
+ chord width and page by line.
387
+
388
+ - **`trainbud check` caused the failure it reported.** A rate-limited Garmin
389
+ login was retried once per tool call, so one expired session became nine logins
390
+ into a Cloudflare 429 in about five seconds. There is now a single cooldown,
391
+ taken from the upstream's own retry-after and honoured before any login is
392
+ attempted; a session already in hand keeps working.
393
+
394
+ - **A test asserted against the developer's environment.** `checkAiStatus`
395
+ cleared `ANTHROPIC_API_KEY` in a `before` hook, but `src/config.ts` calls dotenv
396
+ at module load, so the dynamic import that followed put it straight back. Green
397
+ for the life of the project purely because no key had ever existed on the
398
+ machine it ran on; it failed the day one was added.
399
+
400
+ ### Fixed found by an adversarial sweep
401
+
402
+ A ten-lens audit produced 52 candidates; each went to two independent verifiers
403
+ told to refute it, and 42 survived. The ones fixed here:
404
+
405
+ - **Every daily metric was fetched for the wrong day, west of UTC.**
406
+ garmin-connect derives the calendar day from the Date it is given by
407
+ subtracting `getTimezoneOffset()` — a function that round-trips a
408
+ local-midnight Date and shifts a UTC-midnight one. Every Date this codebase
409
+ produced was UTC midnight, so at UTC-5 the library asked Garmin about the 18th
410
+ while the answer was stored under the 19th. Sleep, resting heart rate, stress
411
+ and weight were all off by one day, silently, and every baseline and finding
412
+ built on them described a day the user did not live. Invisible here because
413
+ this machine is UTC+4. The suite now passes under Los Angeles, UTC, Baku and
414
+ Auckland.
415
+
416
+ - **Sleep debt was noise roughly half the time.** It summed one-sided shortfalls
417
+ against the user's own median, and half of anyone's nights fall below their
418
+ median by construction. Measured over 2000 synthetic steady sleepers with no
419
+ deficit at all it fired on 46.7% of weeks at an hour of night-to-night
420
+ variation. Now measured against a floor; false positives fall to 8.0% while a
421
+ genuine 1.5 h/night deficit is still caught 93% of the time.
422
+
423
+ - **An unworn night scored as a bad night.** Recovery read
424
+ `sleep?.sleepTimeSeconds ?? 0`, scored zero hours as 35/100, and dragged an
425
+ otherwise excellent day under the "fatigued" line.
426
+
427
+ - **A gap in the store read as a run of days.** The recent window was the last N
428
+ *points*, not N days.
429
+
430
+ - **The request handler could not survive a throw.** One unguarded async
431
+ callback, so a malformed `Host` header — accepted by Node's parser, rejected by
432
+ `new URL` exited the process, with no credential required.
433
+
434
+ - **`trainbud setup` destroyed the Anthropic key**, and a Garmin password
435
+ containing `#` broke setup permanently, because values were written to `.env`
436
+ unquoted.
437
+
438
+ - **Every goal and injury was saved twice**; the dashboard bound its form twice.
439
+
440
+ - **`trainbud check` caused the rate limit it reported**, and a backfill answered
441
+ a 429 with ~1800 more requests.
442
+
443
+ - **`app.db` held the Anthropic key at 0644**, and live pairing codes were
444
+ written to the log.
445
+
446
+ - **The glance drew a tofu box** for the "h" in "6.3h"; **an expired pairing code
447
+ was polled forever**; **a rotated API key was hidden** behind ageing cached
448
+ numbers; and **the Ask card accepted presses with no AI key**.
449
+
450
+ - **The only type check CI ran never looked at a test file**, which is why four
451
+ stale fixtures compiled.
452
+
453
+ ### Added
454
+
455
+ - **`trainbud doctor`, `GET /api/selftest`, and a Connection panel on the
456
+ dashboard.** Fetches the configured public URL from outside this machine with
457
+ the watch's own headers and grades the answer with the watch's taxonomy. Status
458
+ alone is not the test — both of this project's expensive network bugs were a
459
+ 200 or a 404 carrying HTML — so the body is graded and a 200 of HTML fails.
460
+
461
+ - **A Week card, and `get_week_review`.** This week against last, per metric,
462
+ split by date rather than by position so a missing day cannot shift the
463
+ boundary. A metric absent on either side reads as unknown rather than as a
464
+ delta against zero.
465
+
466
+ - **A load forecast.** Where the acute:chronic ratio lands if next week repeats
467
+ this one. The existing detector fires after the jump; this fires before it. The
468
+ projection slides the whole 28-day window forward rather than only advancing
469
+ the acute end.
470
+
471
+ - **Sleep debt and consistency**, against the user's own median night rather than
472
+ eight hours, with consistency as median absolute deviation so one recovery
473
+ sleep cannot make a metronomic sleeper look erratic. Surfaced under last
474
+ night's hours as "Usually 7.2h · variable".
475
+
476
+ - **A race countdown.** The context store has held races since the memory layer
477
+ landed and nothing ever read one for its date, though it changes what every
478
+ other number means a falling load ratio is a warning in January and the plan
479
+ in a taper. `activeContext` excludes future dates by design, so
480
+ `upcomingContext` was added.
481
+
482
+ - **`build.ps1 -Screens` and `scripts/capture-screens.ps1`.** Every screen the
483
+ app can draw, with no server, photographed on any device in one command. 1.3.0
484
+ shipped to the store having never been drawn once. Three faults it caught
485
+ immediately: the default jungle `sourcePath` was pulling the debug driver into
486
+ the store build; a jungle setting is assigned rather than appended, so listing
487
+ two jungles silently un-excluded the glance; and the Forerunner 55's recovery
488
+ card collided with its own ring.
489
+
490
+ ## [watch 1.3.1] - 2026-09-02
491
+
492
+ Nobody who installed the watch app from the store could ever pair it. Reported
493
+ from a Forerunner 55 on firmware 11.03; it affected every user on every device.
494
+
495
+ ### Fixed watch
496
+
497
+ - **The store build shipped a default Server URL pointing at a developer's
498
+ personal ngrok tunnel.** From 1.2.0 onward `properties.xml` carried
499
+ `https://backpedal-immorally-cathouse.ngrok-free.dev` as the default, so a
500
+ fresh install found a non-empty URL, skipped the setup screen entirely, POSTed
501
+ `/api/pair` at a host that was usually offline, and rendered "Pairing failed"
502
+ forever. The tunnel answers `ERR_NGROK_3200` when it is not running; the watch
503
+ read the HTML error page as `-400` and reported a pairing failure. The default
504
+ is now empty, which routes a fresh install to a **Setup required** screen
505
+ naming the setup guide. Sideloading keeps a baked URL through the new
506
+ `ciq/monkey-dev.jungle` overlay, which is gitignored and never in a store
507
+ build.
508
+
509
+ The same default was a privacy hazard in the other direction: while that
510
+ tunnel was up, a stranger's watch minted a pairing code against the
511
+ developer's own health server, one approval click away from handing out a
512
+ bearer token to somebody else's Garmin data.
513
+
514
+ - **One "Pairing failed" screen became three, each naming what the user can
515
+ fix.** *Cannot reach server* (nothing answered), *Not a TrainBud server*
516
+ (something answered and it was not us — a dead tunnel, a captive portal, the
517
+ wrong address), *Server refused pairing*. The address in use is drawn on
518
+ screen whenever the address is the suspect, and `-104`, `-1001` and `429` get
519
+ their own one-line hints. `-1001` distinguishes a plain `http://` URL from an
520
+ `https://` one whose certificate was refused: the same code, two different
521
+ problems, and telling a self-hosted user to "use https" when they already do
522
+ is a dead end.
523
+
524
+ - **Round screens were detected by measuring pixels rather than asking the
525
+ device.** `isRoundScreen()` was `width == height && width >= 240`, so the
526
+ Forerunner 55 round, 208×208 was treated as rectangular and drew the
527
+ recovery bar instead of the ring. Now `System.getDeviceSettings().screenShape`.
528
+
529
+ - **Text wrapped to a fixed character count, which a circle does not honour.**
530
+ A line near the top of a round screen has far less room than one through the
531
+ middle; "Cannot reach server" rendered as "annot reach serve". Wrapping is now
532
+ measured in pixels against the chord width available at that height.
533
+
534
+ - **The five button-only products could not navigate.** `BehaviorDelegate` maps
535
+ UP and DOWN to `onNextPage`/`onPreviousPage` and neither was implemented, so
536
+ on fr55, fr745 and the three Instinct 3 variants the carousel only moved
537
+ forwards one START press at a time and the Ask menu could not be scrolled at
538
+ all. Every on-screen hint also said "tap"; hints are now chosen from
539
+ `System.getDeviceSettings().isTouchScreen`.
540
+
541
+ - **Grey was invisible on the Forerunner 55.** Its palette holds eight colours
542
+ and none of them is grey, so `COLOR_DK_GRAY` snapped to black on a black
543
+ background: inactive page dots, faded Ask items, card footnotes and the ring
544
+ track all vanished. Secondary elements use a colour that survives the palette,
545
+ and inactive dots are outlined rather than filled, so the hierarchy is carried
546
+ by shape.
547
+
548
+ - **Pairing telemetry is debug-only.** The pairing screen drew `9/8 200` in the
549
+ corner of a store build unexplainable to a user, and the difference between
550
+ a poll that never ran and one discarded on the device to anyone debugging.
551
+
552
+ - **The AI disclaimer is now on screen.** The string existed from 1.2.0 and was
553
+ never drawn anywhere, so the app made training and recovery statements on a
554
+ health device with nothing to qualify them. It renders on the last page of an
555
+ answer.
556
+
557
+ ### Fixed — watch, found by drawing it
558
+
559
+ Everything below was found by running 1.3.1 in the simulator on a Forerunner 55
560
+ and a fenix 8 47mm and looking at every card. None of it was visible from the
561
+ source, the type checker or a green build, and none of these screens had ever
562
+ been drawn on any watch: 1.3.0 shipped its `.iq` without a single render.
563
+
564
+ - **The Today card printed its first finding through its own title,** and cut a
565
+ character off each end of it: "Resting HR 4 bpm above" drew as "esting HR 4 bpm
566
+ above". It centred the text block on the screen rather than in the space below
567
+ the heading, and wrapped to a fixed 24 characters. This is card 0 — the first
568
+ thing a paired user sees.
569
+
570
+ - **The Sleep card drew an empty yellow box next to "6.3".** The FONT_NUMBER_*
571
+ faces contain digits and separators and no letters, so the "h" rendered as a
572
+ missing-glyph box and a box has a width, so the "does this fit" check passed
573
+ and the value never stepped down to a font that has letters. The Recovery
574
+ card's "No data" had the same fault waiting.
575
+
576
+ - **The Ask AI card printed the selected prompt underneath its own hint:**
577
+ "Why is my re[STA]sting HR up?". The hint was right-justified against the
578
+ screen edge at the vertical centre, which on a circle is exactly where the
579
+ widest line already is.
580
+
581
+ - **The carousel dead-ended on the Ask card.** Going back from the first prompt
582
+ left the card, but going forward from the last one wrapped around, so the six
583
+ metric cards after it could only be reached by paging *backwards* from Today.
584
+ It now leaves at both ends, on buttons and on swipe.
585
+
586
+ - **The AI Insight card overlapped its own lines on large screens.** The line
587
+ step was a hardcoded 20 px, which is about right for the 208 px Forerunner 55
588
+ and much too small for a 454 px fenix. Every hardcoded line step is now
589
+ derived from the font.
590
+
591
+ - **The Recovery card printed "Ready" through the bottom of the score,** for the
592
+ same reason: the label sat at a fixed offset from the centre while the score
593
+ is drawn in a font whose height nearly doubles between those two devices. The
594
+ label and the heart rate line are now stacked off the score's measured height.
595
+
596
+ - **"Resting 48 Max 178" lost its last digits** at the bottom of a 208 px round
597
+ screen. It now shortens the *label* before the number: "Rest 48 Max 178".
598
+
599
+ - Activity card: the workout name was cut to 14 characters before being drawn,
600
+ which threw away room a smaller font would have used. `drawFittedValue`
601
+ already measures and steps down; the character cut is gone. This was the last
602
+ item open on that card.
603
+
604
+ ### Fixed "AI unavailable" meant four different things
605
+
606
+ Reported as "it still shows AI Unavailable". The cause on this install was that
607
+ **no Anthropic key had ever been configured**: the settings table was empty,
608
+ `.env` had no `ANTHROPIC_API_KEY`, and the only two prompt jobs ever created, on
609
+ 2026-08-17 and 2026-08-19, both failed with
610
+ `ANTHROPIC_API_KEY not configured`. AI is bring-your-own-key and genuinely could
611
+ not run. The defect is that the watch could not say so.
612
+
613
+ - **`/api/watch` now carries `ai_configured`.** `ai_insight: null` meant three
614
+ different things — no key, a failed call, or today's insight not generated
615
+ yet — and the watch drew one screen for all of them. The Ask card now shows
616
+ **"AI not set up / Add an API key in the dashboard"** instead of offering five
617
+ questions that cannot succeed, and the AI Insight card no longer tells a user
618
+ with no key that there is "No insight today", which is a claim about today. A
619
+ summary from an older server carries no such field; missing is read as
620
+ configured, so nothing is asserted on no evidence.
621
+
622
+ - **The watch threw away the reason a prompt failed.** `GET /api/prompt/<id>`
623
+ returns the server's error string, and `onPromptStatusReceived` read only
624
+ `status` so a missing API key and a provider outage rendered identically.
625
+ The reason is now drawn under the message, along with a timeout and the HTTP
626
+ code from a failed submit.
627
+
628
+ - **`trainbud check` reported AI status from the environment only.** It read
629
+ `appConfig.anthropicApiKey` instead of `isAiConfigured()`, so a key saved
630
+ through the dashboard the route the setup guide tells users to take — was
631
+ reported as absent forever. `resolveAnthropicKey()` exists for exactly this
632
+ and this call site never used it.
633
+
634
+ ### Fixed dependencies
635
+
636
+ - `qs` 6.15.3 6.16.0 and `fast-uri` 3.1.5 → 3.1.7, clearing one high and one
637
+ moderate advisory. `npm audit` is clean.
638
+
639
+ ### Removed
640
+
641
+ - Dead resources and code: `drawHint()` and the `TapHint` string it drew
642
+ (replaced by page dots in 1.2.0), plus the unused `AskHint`, `PairingPolling`
643
+ and `CardHeartRate` strings.
644
+
645
+ ## [0.3.1] - 2026-08-19
646
+
647
+ Watch pairing works. It never had, on any build, and the cause was the last one
648
+ still open: a status poll that never reached the server.
649
+
650
+ ### Fixed watch
651
+
652
+ - **Pairing completes end to end.** ngrok's free tier answers any GET carrying a
653
+ browser-ish User-Agent with an HTML interstitial under a 200. Connect IQ sends
654
+ `Mozilla/5.0` and will not let an app override it, so every status poll was
655
+ answered by the tunnel, never reached the server, and failed on the watch as
656
+ `-400 INVALID_HTTP_BODY_IN_NETWORK_RESPONSE`. POSTs are not intercepted, which
657
+ is why `/api/pair` always worked and the poll never did. Verified in the
658
+ simulator against the live tunnel: code issued, approved in the dashboard,
659
+ credentials saved, summary fetched.
660
+ - Recovery ring drew the inverse of the score: the end angle is measured
661
+ clockwise but the arc was drawn counter-clockwise, so 91 rendered as the
662
+ missing 9%.
663
+ - Recovery ring overlapped the card title.
664
+ - Fractional values drew with six decimal places (`6.300000h`), on both the
665
+ widget and the glance.
666
+ - The four-cell overview grid used "No data" as its placeholder, which drew over
667
+ the neighbouring cells and their labels; it now uses a dash.
668
+ - Activity card: subtitle and footnote collided, and a strength workout listed
669
+ "0 km" as though zero were a measurement.
670
+
671
+ ### Fixed — server
672
+
673
+ - **Recovery score was NaN on every default call** and reached the watch as
674
+ `null`: normalizeWeights spread an object of explicit `undefined` weights over
675
+ its defaults, and `NaN <= 0` is false so the guard missed it.
676
+ - **Stress and VO2 max were fetched from URLs Connect answers 404 for.** Both
677
+ take the date as a path segment; VO2 max is `maxmet/latest`, not
678
+ `maxmet/daily`. The stress mapper also read fields the response does not
679
+ contain (`overallStressLevel` rather than `avgStressLevel`), and Connect's
680
+ negative "not measured" sentinels were averaged in as real readings.
681
+ - Pair codes came from `Math.random()`. `/api/pair` is unauthenticated by
682
+ design, so an attacker could mint codes, recover the PRNG state, predict the
683
+ code the watch was showing and collect the API key on approval. Now
684
+ `crypto.randomInt`.
685
+ - Rate limiting only ever ran on `/mcp`, leaving the unauthenticated pair
686
+ endpoints open to a walk through the six-digit code space; and buckets were
687
+ keyed on the socket address, which behind a tunnel is one bucket for every
688
+ client. Limits now cover every route, with a tighter budget for pairing, keyed
689
+ on the forwarded address.
690
+ - API key compared in constant time.
691
+ - `.env`, `session.json` and the MCP client config were written world-readable
692
+ (0644) and are now 0600.
693
+ - A single failed Garmin login was memoised and poisoned every later call until
694
+ the process restarted.
695
+ - All four npm audit advisories cleared.
696
+
697
+ ### Added
698
+
699
+ - Real Connect IQ Store screenshots, captured from a paired app in the
700
+ simulator (`ciq/store/screenshots/`). The previous set was drawn in PowerShell
701
+ and removed in 1.2.0.
702
+ - Tests: pair code randomness, pair rate limiting, secret file permissions,
703
+ client auth lifecycle, the real Connect stress payload, recovery weights.
704
+ 91 tests, up from 73.
705
+
706
+ ## [0.2.0] - 2026-06-26
707
+
708
+ ### Fixed
709
+
710
+ - **Critical:** Logger no longer writes to stdout — pino-pretty uses stderr; file logging starts only when server starts
711
+ - **Critical:** Credentials validated at server startup, not on first tool call
712
+ - N+1 Garmin API calls batched with concurrency limit (`mapInBatches`, max 6 parallel)
713
+ - Body composition fetches parallelized instead of sequential
714
+ - Activities range queries use shared paginated pool cache (up to 500 activities) with truncation warning
715
+ - Cache keys unified via `buildToolCacheKey()` with stable sorted-param hashing
716
+ - Recovery tool uses yesterday's sleep data with fallback to prior nights
717
+ - Activity date filtering uses consistent Luxon parsing
718
+ - Session path resolved from single `getSessionPath()` in config
719
+ - Version read from `package.json` instead of hardcoded strings
720
+ - `GarminApiError` is now a proper class; auth retry uses `await`
721
+ - Tool errors sanitized before returning to MCP clients
722
+ - SQLite cache closed on SIGTERM/SIGINT/exit
723
+
724
+ ### Added
725
+
726
+ - `src/version.ts`, `src/utils/batch.ts`, `src/garmin/garminApiTypes.ts`, `src/tools/types.ts`
727
+ - `filterActivitiesByRange()`, `sanitizeErrorMessage()`, `getYesterday()` helpers
728
+ - `configureLogger()` for lazy log file initialization
729
+ - `.nvmrc` (Node 20)
730
+ - `.github/workflows/publish.yml` for npm + GitHub Releases on version tags
731
+ - Project knowledge base moved to Obsidian vault (`05-Projects/trainbud/`); see `docs/VAULT.md`
732
+ - 11 new tests (32 total): cache key stability, date filtering, recovery scoring, error sanitization
733
+
734
+ ### Changed
735
+
736
+ - **Rebranded** from garmin-mcp to **TrainBud** (package `trainbud`, CLI `trainbud`)
737
+ - README rewritten as product page with disclaimer, badges, and security section
738
+ - Added CONTRIBUTING.md; updated vault docs and examples
739
+ - Removed imports from internal `garmin-connect/dist/` paths
740
+ - Tool registry uses shared `ToolDefinition` interface without unsafe casts
741
+ - `runCacheClear` is synchronous
742
+
743
+ ## [0.1.0] - 2026-06-26
744
+
745
+ ### Added
746
+
747
+ - Initial MCP server exposing 6 Garmin Connect tools
748
+ - Email/password authentication with session persistence (`.trainbud/session.json`)
749
+ - SQLite caching layer with configurable TTL per resource type
750
+ - CLI commands: `start`, `auth`, `cache clear`, `status`
751
+ - Unit and integration tests using Node test runner
752
+ - README, QUICKSTART, and example prompts
753
+
754
+ ### Notes
755
+
756
+ - Uses unofficial `garmin-connect` npm package (Windows/macOS/Linux compatible)
757
+ - MFA is not yet supported by the underlying library