trainbud 0.5.0 → 0.5.1

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