trainbud 0.5.0

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 (274) hide show
  1. package/.env.example +11 -0
  2. package/CHANGELOG.md +675 -0
  3. package/LICENSE +21 -0
  4. package/QUICKSTART.md +182 -0
  5. package/README.md +322 -0
  6. package/dist/appDb.d.ts +83 -0
  7. package/dist/appDb.d.ts.map +1 -0
  8. package/dist/appDb.js +239 -0
  9. package/dist/appDb.js.map +1 -0
  10. package/dist/check.d.ts +12 -0
  11. package/dist/check.d.ts.map +1 -0
  12. package/dist/check.js +230 -0
  13. package/dist/check.js.map +1 -0
  14. package/dist/cli.d.ts +3 -0
  15. package/dist/cli.d.ts.map +1 -0
  16. package/dist/cli.js +457 -0
  17. package/dist/cli.js.map +1 -0
  18. package/dist/config.d.ts +61 -0
  19. package/dist/config.d.ts.map +1 -0
  20. package/dist/config.js +241 -0
  21. package/dist/config.js.map +1 -0
  22. package/dist/dashboard.d.ts +8 -0
  23. package/dist/dashboard.d.ts.map +1 -0
  24. package/dist/dashboard.js +1004 -0
  25. package/dist/dashboard.js.map +1 -0
  26. package/dist/dashboardCharts.d.ts +99 -0
  27. package/dist/dashboardCharts.d.ts.map +1 -0
  28. package/dist/dashboardCharts.js +317 -0
  29. package/dist/dashboardCharts.js.map +1 -0
  30. package/dist/dashboardData.d.ts +89 -0
  31. package/dist/dashboardData.d.ts.map +1 -0
  32. package/dist/dashboardData.js +228 -0
  33. package/dist/dashboardData.js.map +1 -0
  34. package/dist/detect/baseline.d.ts +17 -0
  35. package/dist/detect/baseline.d.ts.map +1 -0
  36. package/dist/detect/baseline.js +62 -0
  37. package/dist/detect/baseline.js.map +1 -0
  38. package/dist/detect/countdown.d.ts +30 -0
  39. package/dist/detect/countdown.d.ts.map +1 -0
  40. package/dist/detect/countdown.js +79 -0
  41. package/dist/detect/countdown.js.map +1 -0
  42. package/dist/detect/detectors.d.ts +20 -0
  43. package/dist/detect/detectors.d.ts.map +1 -0
  44. package/dist/detect/detectors.js +291 -0
  45. package/dist/detect/detectors.js.map +1 -0
  46. package/dist/detect/findings.d.ts +29 -0
  47. package/dist/detect/findings.d.ts.map +1 -0
  48. package/dist/detect/findings.js +2 -0
  49. package/dist/detect/findings.js.map +1 -0
  50. package/dist/detect/forecast.d.ts +33 -0
  51. package/dist/detect/forecast.d.ts.map +1 -0
  52. package/dist/detect/forecast.js +122 -0
  53. package/dist/detect/forecast.js.map +1 -0
  54. package/dist/detect/index.d.ts +50 -0
  55. package/dist/detect/index.d.ts.map +1 -0
  56. package/dist/detect/index.js +114 -0
  57. package/dist/detect/index.js.map +1 -0
  58. package/dist/detect/sleepQuality.d.ts +21 -0
  59. package/dist/detect/sleepQuality.d.ts.map +1 -0
  60. package/dist/detect/sleepQuality.js +164 -0
  61. package/dist/detect/sleepQuality.js.map +1 -0
  62. package/dist/detect/trimp.d.ts +22 -0
  63. package/dist/detect/trimp.d.ts.map +1 -0
  64. package/dist/detect/trimp.js +77 -0
  65. package/dist/detect/trimp.js.map +1 -0
  66. package/dist/detect/week.d.ts +31 -0
  67. package/dist/detect/week.d.ts.map +1 -0
  68. package/dist/detect/week.js +184 -0
  69. package/dist/detect/week.js.map +1 -0
  70. package/dist/garmin/auth.d.ts +10 -0
  71. package/dist/garmin/auth.d.ts.map +1 -0
  72. package/dist/garmin/auth.js +81 -0
  73. package/dist/garmin/auth.js.map +1 -0
  74. package/dist/garmin/cache.d.ts +42 -0
  75. package/dist/garmin/cache.d.ts.map +1 -0
  76. package/dist/garmin/cache.js +141 -0
  77. package/dist/garmin/cache.js.map +1 -0
  78. package/dist/garmin/client.d.ts +22 -0
  79. package/dist/garmin/client.d.ts.map +1 -0
  80. package/dist/garmin/client.js +237 -0
  81. package/dist/garmin/client.js.map +1 -0
  82. package/dist/garmin/daily.d.ts +24 -0
  83. package/dist/garmin/daily.d.ts.map +1 -0
  84. package/dist/garmin/daily.js +96 -0
  85. package/dist/garmin/daily.js.map +1 -0
  86. package/dist/garmin/garminApiTypes.d.ts +70 -0
  87. package/dist/garmin/garminApiTypes.d.ts.map +1 -0
  88. package/dist/garmin/garminApiTypes.js +3 -0
  89. package/dist/garmin/garminApiTypes.js.map +1 -0
  90. package/dist/garmin/garminConnect.d.ts +17 -0
  91. package/dist/garmin/garminConnect.d.ts.map +1 -0
  92. package/dist/garmin/garminConnect.js +5 -0
  93. package/dist/garmin/garminConnect.js.map +1 -0
  94. package/dist/garmin/partial.d.ts +32 -0
  95. package/dist/garmin/partial.d.ts.map +1 -0
  96. package/dist/garmin/partial.js +38 -0
  97. package/dist/garmin/partial.js.map +1 -0
  98. package/dist/garmin/rawApi.d.ts +59 -0
  99. package/dist/garmin/rawApi.d.ts.map +1 -0
  100. package/dist/garmin/rawApi.js +113 -0
  101. package/dist/garmin/rawApi.js.map +1 -0
  102. package/dist/garmin/types.d.ts +85 -0
  103. package/dist/garmin/types.d.ts.map +1 -0
  104. package/dist/garmin/types.js +11 -0
  105. package/dist/garmin/types.js.map +1 -0
  106. package/dist/history/capture.d.ts +12 -0
  107. package/dist/history/capture.d.ts.map +1 -0
  108. package/dist/history/capture.js +63 -0
  109. package/dist/history/capture.js.map +1 -0
  110. package/dist/history/context.d.ts +51 -0
  111. package/dist/history/context.d.ts.map +1 -0
  112. package/dist/history/context.js +122 -0
  113. package/dist/history/context.js.map +1 -0
  114. package/dist/history/fallback.d.ts +72 -0
  115. package/dist/history/fallback.d.ts.map +1 -0
  116. package/dist/history/fallback.js +185 -0
  117. package/dist/history/fallback.js.map +1 -0
  118. package/dist/history/ingest.d.ts +44 -0
  119. package/dist/history/ingest.d.ts.map +1 -0
  120. package/dist/history/ingest.js +347 -0
  121. package/dist/history/ingest.js.map +1 -0
  122. package/dist/history/scheduler.d.ts +20 -0
  123. package/dist/history/scheduler.d.ts.map +1 -0
  124. package/dist/history/scheduler.js +54 -0
  125. package/dist/history/scheduler.js.map +1 -0
  126. package/dist/history/schema.d.ts +41 -0
  127. package/dist/history/schema.d.ts.map +1 -0
  128. package/dist/history/schema.js +91 -0
  129. package/dist/history/schema.js.map +1 -0
  130. package/dist/history/store.d.ts +112 -0
  131. package/dist/history/store.d.ts.map +1 -0
  132. package/dist/history/store.js +282 -0
  133. package/dist/history/store.js.map +1 -0
  134. package/dist/httpServer.d.ts +55 -0
  135. package/dist/httpServer.d.ts.map +1 -0
  136. package/dist/httpServer.js +1027 -0
  137. package/dist/httpServer.js.map +1 -0
  138. package/dist/index.d.ts +3 -0
  139. package/dist/index.d.ts.map +1 -0
  140. package/dist/index.js +11 -0
  141. package/dist/index.js.map +1 -0
  142. package/dist/mcpConfig.d.ts +56 -0
  143. package/dist/mcpConfig.d.ts.map +1 -0
  144. package/dist/mcpConfig.js +106 -0
  145. package/dist/mcpConfig.js.map +1 -0
  146. package/dist/pairApi.d.ts +14 -0
  147. package/dist/pairApi.d.ts.map +1 -0
  148. package/dist/pairApi.js +38 -0
  149. package/dist/pairApi.js.map +1 -0
  150. package/dist/paths.d.ts +45 -0
  151. package/dist/paths.d.ts.map +1 -0
  152. package/dist/paths.js +105 -0
  153. package/dist/paths.js.map +1 -0
  154. package/dist/profile.d.ts +123 -0
  155. package/dist/profile.d.ts.map +1 -0
  156. package/dist/profile.js +327 -0
  157. package/dist/profile.js.map +1 -0
  158. package/dist/promptApi.d.ts +43 -0
  159. package/dist/promptApi.d.ts.map +1 -0
  160. package/dist/promptApi.js +328 -0
  161. package/dist/promptApi.js.map +1 -0
  162. package/dist/promptSuggestions.d.ts +16 -0
  163. package/dist/promptSuggestions.d.ts.map +1 -0
  164. package/dist/promptSuggestions.js +141 -0
  165. package/dist/promptSuggestions.js.map +1 -0
  166. package/dist/selfTest.d.ts +53 -0
  167. package/dist/selfTest.d.ts.map +1 -0
  168. package/dist/selfTest.js +225 -0
  169. package/dist/selfTest.js.map +1 -0
  170. package/dist/server.d.ts +10 -0
  171. package/dist/server.d.ts.map +1 -0
  172. package/dist/server.js +67 -0
  173. package/dist/server.js.map +1 -0
  174. package/dist/setup.d.ts +2 -0
  175. package/dist/setup.d.ts.map +1 -0
  176. package/dist/setup.js +210 -0
  177. package/dist/setup.js.map +1 -0
  178. package/dist/toolErrors.d.ts +2 -0
  179. package/dist/toolErrors.d.ts.map +1 -0
  180. package/dist/toolErrors.js +18 -0
  181. package/dist/toolErrors.js.map +1 -0
  182. package/dist/tools/activities.d.ts +18 -0
  183. package/dist/tools/activities.d.ts.map +1 -0
  184. package/dist/tools/activities.js +182 -0
  185. package/dist/tools/activities.js.map +1 -0
  186. package/dist/tools/bodyComposition.d.ts +10 -0
  187. package/dist/tools/bodyComposition.d.ts.map +1 -0
  188. package/dist/tools/bodyComposition.js +139 -0
  189. package/dist/tools/bodyComposition.js.map +1 -0
  190. package/dist/tools/context.d.ts +8 -0
  191. package/dist/tools/context.d.ts.map +1 -0
  192. package/dist/tools/context.js +148 -0
  193. package/dist/tools/context.js.map +1 -0
  194. package/dist/tools/findings.d.ts +7 -0
  195. package/dist/tools/findings.d.ts.map +1 -0
  196. package/dist/tools/findings.js +51 -0
  197. package/dist/tools/findings.js.map +1 -0
  198. package/dist/tools/heartRate.d.ts +10 -0
  199. package/dist/tools/heartRate.d.ts.map +1 -0
  200. package/dist/tools/heartRate.js +105 -0
  201. package/dist/tools/heartRate.js.map +1 -0
  202. package/dist/tools/index.d.ts +66 -0
  203. package/dist/tools/index.d.ts.map +1 -0
  204. package/dist/tools/index.js +101 -0
  205. package/dist/tools/index.js.map +1 -0
  206. package/dist/tools/payloads.d.ts +148 -0
  207. package/dist/tools/payloads.d.ts.map +1 -0
  208. package/dist/tools/payloads.js +2 -0
  209. package/dist/tools/payloads.js.map +1 -0
  210. package/dist/tools/recovery.d.ts +31 -0
  211. package/dist/tools/recovery.d.ts.map +1 -0
  212. package/dist/tools/recovery.js +350 -0
  213. package/dist/tools/recovery.js.map +1 -0
  214. package/dist/tools/sleep.d.ts +10 -0
  215. package/dist/tools/sleep.d.ts.map +1 -0
  216. package/dist/tools/sleep.js +124 -0
  217. package/dist/tools/sleep.js.map +1 -0
  218. package/dist/tools/stress.d.ts +11 -0
  219. package/dist/tools/stress.d.ts.map +1 -0
  220. package/dist/tools/stress.js +108 -0
  221. package/dist/tools/stress.js.map +1 -0
  222. package/dist/tools/trainingInsights.d.ts +15 -0
  223. package/dist/tools/trainingInsights.d.ts.map +1 -0
  224. package/dist/tools/trainingInsights.js +79 -0
  225. package/dist/tools/trainingInsights.js.map +1 -0
  226. package/dist/tools/types.d.ts +12 -0
  227. package/dist/tools/types.d.ts.map +1 -0
  228. package/dist/tools/types.js +2 -0
  229. package/dist/tools/types.js.map +1 -0
  230. package/dist/tools/vo2Max.d.ts +11 -0
  231. package/dist/tools/vo2Max.d.ts.map +1 -0
  232. package/dist/tools/vo2Max.js +110 -0
  233. package/dist/tools/vo2Max.js.map +1 -0
  234. package/dist/tools/week.d.ts +12 -0
  235. package/dist/tools/week.d.ts.map +1 -0
  236. package/dist/tools/week.js +74 -0
  237. package/dist/tools/week.js.map +1 -0
  238. package/dist/usage.d.ts +104 -0
  239. package/dist/usage.d.ts.map +1 -0
  240. package/dist/usage.js +247 -0
  241. package/dist/usage.js.map +1 -0
  242. package/dist/utils/batch.d.ts +2 -0
  243. package/dist/utils/batch.d.ts.map +1 -0
  244. package/dist/utils/batch.js +18 -0
  245. package/dist/utils/batch.js.map +1 -0
  246. package/dist/utils/helpers.d.ts +32 -0
  247. package/dist/utils/helpers.d.ts.map +1 -0
  248. package/dist/utils/helpers.js +188 -0
  249. package/dist/utils/helpers.js.map +1 -0
  250. package/dist/utils/jsonFile.d.ts +19 -0
  251. package/dist/utils/jsonFile.d.ts.map +1 -0
  252. package/dist/utils/jsonFile.js +24 -0
  253. package/dist/utils/jsonFile.js.map +1 -0
  254. package/dist/utils/logger.d.ts +4 -0
  255. package/dist/utils/logger.d.ts.map +1 -0
  256. package/dist/utils/logger.js +97 -0
  257. package/dist/utils/logger.js.map +1 -0
  258. package/dist/utils/secretFile.d.ts +15 -0
  259. package/dist/utils/secretFile.d.ts.map +1 -0
  260. package/dist/utils/secretFile.js +47 -0
  261. package/dist/utils/secretFile.js.map +1 -0
  262. package/dist/utils/stdio.d.ts +10 -0
  263. package/dist/utils/stdio.d.ts.map +1 -0
  264. package/dist/utils/stdio.js +70 -0
  265. package/dist/utils/stdio.js.map +1 -0
  266. package/dist/version.d.ts +2 -0
  267. package/dist/version.d.ts.map +1 -0
  268. package/dist/version.js +7 -0
  269. package/dist/version.js.map +1 -0
  270. package/dist/watchApi.d.ts +264 -0
  271. package/dist/watchApi.d.ts.map +1 -0
  272. package/dist/watchApi.js +333 -0
  273. package/dist/watchApi.js.map +1 -0
  274. package/package.json +77 -0
package/CHANGELOG.md ADDED
@@ -0,0 +1,675 @@
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