Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
50 commits
Select commit Hold shift + click to select a range
4874573
barcode lookup is on by default
abdulsaheel Aug 19, 2026
425fcc0
podfile.lock: mobile_scanner in, video_player out
abdulsaheel Aug 19, 2026
9fef332
import: route by what the file holds, not what it's called
abdulsaheel Aug 19, 2026
ba52200
coach: serialize the keychain writes (#241)
abdulsaheel Aug 19, 2026
ae23ac8
ai briefing: the payload preview shows what was sent, not a dash
abdulsaheel Aug 19, 2026
edada74
readme: whoop 5 and mg work, say so
abdulsaheel Aug 19, 2026
0be0501
pr agent: don't go green without reviewing anything (#230)
abdulsaheel Aug 19, 2026
e38c218
bump health to 12.2.1 — 11.1.1 threw on every light-sleep write
abdulsaheel Aug 19, 2026
c797b52
double-tap can log water
abdulsaheel Aug 19, 2026
f6ae062
device_actions: name the files that exist
abdulsaheel Aug 19, 2026
2b98a8c
the double-tap picker, which never got rebuilt
abdulsaheel Aug 19, 2026
f736463
import: don't read a zip as a string on the journal probe
abdulsaheel Aug 19, 2026
fcdf022
ios: delete the whole night, not the calendar day
abdulsaheel Aug 19, 2026
ac3896c
detected workouts had nowhere to go, and manual logging had no screen…
abdulsaheel Aug 19, 2026
9e90e10
ios: write the in-bed envelope around the stages
abdulsaheel Aug 19, 2026
a8b874e
auto-detection gets an off switch, and the movement nudge gets one it…
abdulsaheel Aug 19, 2026
e250fb8
the notification→strap relay has a screen again (#92)
abdulsaheel Aug 19, 2026
9c49ac6
notifications settings: the three rows behind all of that
abdulsaheel Aug 19, 2026
b580d7a
note the missing health export on the coach's workout write (#130)
abdulsaheel Aug 19, 2026
799e8e0
health export seam takes a workout id (#130)
abdulsaheel Aug 19, 2026
1b90add
import: one unix_s header constant, not two
abdulsaheel Aug 19, 2026
e8e1bb0
readiness bands: 50 is the middle of the scale, not a warning (#250)
abdulsaheel Aug 19, 2026
d43353d
calories: pass the resting HR the new active gate needs
abdulsaheel Aug 19, 2026
effb90d
strain: state which quiet-waking level we mean
abdulsaheel Aug 19, 2026
88267d9
readiness: pass the settled fraction, so skin temp can actually be a …
abdulsaheel Aug 19, 2026
2a1f6ac
peak hr: the day peak and the manual save go through the same smoothi…
abdulsaheel Aug 19, 2026
af8744a
sleep: a night never re-stages shorter than the one already banked (#…
abdulsaheel Aug 19, 2026
74850a0
decode: absent accel stays absent, not 0
abdulsaheel Aug 19, 2026
7bfe440
tests: the energy fixtures need a resting HR now
abdulsaheel Aug 19, 2026
d4f7229
tier sentinel: 65 is "good to go" now, publish a mid-band score instead
abdulsaheel Aug 19, 2026
40cb3a1
v25: protocol hands us no vector at all now
abdulsaheel Aug 19, 2026
468c1f1
every workout write path exports, including the coach's
abdulsaheel Aug 19, 2026
7cd399c
the double-tap picker has a door again
abdulsaheel Aug 19, 2026
e8213a7
the sawtooth test straddles the gate it asks for
abdulsaheel Aug 19, 2026
5f1136c
algo 75, and repin both siblings
abdulsaheel Aug 19, 2026
66b0070
the ios delete asks for a type healthkit has never heard of
abdulsaheel Aug 19, 2026
b4e0ae6
the briefing kept its own readiness cuts and the ring moved
abdulsaheel Aug 19, 2026
0823d1f
auto-detect off, and the screen the notification opens still loaded them
abdulsaheel Aug 19, 2026
7a30252
barcode lookup defaults on, but not when we can't read the switch
abdulsaheel Aug 19, 2026
405db73
the relay screen said nothing is stored, and it stores package names
abdulsaheel Aug 19, 2026
393af74
a load that starts during a save straddles it and wins
abdulsaheel Aug 19, 2026
93c16a3
the router judged byte zero, the reader doesn't
abdulsaheel Aug 19, 2026
b8cd547
skipping the only step still leaves the job green
abdulsaheel Aug 19, 2026
14089ee
repin both siblings, and the third copy of the gate
abdulsaheel Aug 19, 2026
8972379
a revocation that didn't reach disk isn't a revocation
abdulsaheel Aug 19, 2026
3ff2a0c
a full buffer and a 4096-byte file are not the same thing
abdulsaheel Aug 19, 2026
a32b121
the seen cap could evict an app that was still buzzing
abdulsaheel Aug 19, 2026
385eef4
fix: apply CodeRabbit auto-fixes
coderabbitai[bot] Aug 20, 2026
20aafc0
a consent write that throws is a false, not an exception
abdulsaheel Aug 20, 2026
5fcee62
one catch, at the source
abdulsaheel Aug 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 29 additions & 1 deletion .github/workflows/pr-agent.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,37 @@ jobs:
issues: write
pull-requests: write
contents: write
# Job-level so the step `if` below can see it: the `secrets` context is not
# available in an `if` expression, but `env` is.
env:
PR_AGENT_API_KEY: ${{ secrets.PR_AGENT_API_KEY }}
steps:
# A PR from a fork gets no secrets, so the step below ran with an empty
# key, reviewed nothing, and still went green - a check that says
# "reviewed" when it did not is worse than no check.
#
# Skipping the step alone did not fix that: the only step is skipped, the
# JOB still reports success, and a green required check still reads as a
# pass. So SAY SO, in the one place a reader of the PR looks - the check's
# summary - and make the log line an annotation on the PR itself.
- name: Not applicable - no review key on this PR
if: env.PR_AGENT_API_KEY == ''
run: |
echo "::notice title=PR Agent did not run::No review key is available \
on this pull request (forks get no secrets), so NOTHING was reviewed. \
A green check here means the job finished, not that the diff passed."
{
echo "## PR Agent: not applicable"
echo
echo "No \`PR_AGENT_API_KEY\` on this run - a fork PR gets no"
echo "secrets. **No review was performed.** Treat this check as"
echo "absent, not as a pass."
} >> "$GITHUB_STEP_SUMMARY"
- name: PR Agent action step
uses: the-pr-agent/pr-agent@main
if: env.PR_AGENT_API_KEY != ''
Comment thread
coderabbitai[bot] marked this conversation as resolved.
# Pinned, not @main: this action runs with `contents: write` and a token
# on every PR, and a floating ref means whatever landed upstream today.
uses: the-pr-agent/pr-agent@f6af7d77554ff8d26adffded077e6461329e92fa # v0.42.0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Credentials only. The model chain lives in .pr_agent.toml so it is
Expand Down
8 changes: 6 additions & 2 deletions .pr_agent.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,13 @@ fallback_models = [
"openai/gpt-oss-120b-medium",
]
# Required: an `openai/`-prefixed name is not in PR-Agent's MAX_TOKENS map, and
# get_max_tokens (algo/utils.py:1008) raises rather than defaulting. Effective
# input is still min(this, max_model_tokens=32000).
# get_max_tokens (algo/utils.py:1008) raises rather than defaulting.
custom_model_max_tokens = 200000
# The effective input is min(custom_model_max_tokens, max_model_tokens), and
# max_model_tokens defaults to 32000 - so the 200k above bought nothing and a
# large diff was silently clipped to a third of the review it looked like it
# got. Raise the ceiling to match.
max_model_tokens = 200000

# Inject AGENTS.md as repository context into /review, /improve, /describe, /ask.
# NOTE: read from the DEFAULT BRANCH by default, so AGENTS.md only takes effect
Expand Down
9 changes: 5 additions & 4 deletions PRIVACY.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,9 +64,10 @@ because a sideloaded app has no store to tell you a security fix exists.

**Barcode lookup for food logging**
The food log can read a barcode with the camera and fill in the nutrition
figures for you. Doing that means asking a database, so it is **off until you
turn it on**, and the App asks you before the first lookup ever happens — not
after.
figures for you. Doing that means asking a database, so it is **on by
default**: what leaves is a number the manufacturer printed on the packet, and
nothing about you goes with it. Turn it off and the scan stops asking anybody
anything.

- **What is sent is the barcode.** It goes to openfoodfacts.org, the free and
open food database. Nothing about you, your meals, your health or your device
Expand Down Expand Up @@ -157,7 +158,7 @@ moment you tap it:
further upload immediately, and the App tells you when the last one was.
- **Check for updates** — turning it off stops the App making any network
request of its own accord.
- **Look barcodes up online** — off by default; turning it off stops any
- **Look barcodes up online** — on by default; turning it off stops any
further lookup immediately, and the food log keeps working by hand.

You can also disable AI Coach or Health app integration at any time in Settings
Expand Down
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,8 @@ drawer-bracelet problem can use it, or go dig through the code themselves.

## Checklist

- **WHOOP 4.0 only.** Haven't touched a WHOOP 5, don't know if it even shares a protocol.
- **WHOOP 4.0 is the one that's properly tested.** WHOOP 5 and MG work too, but they're
experimental — see the note further down.
- Not affiliated with WHOOP, doesn't talk to their servers.
- Not a clone of their algorithms — different math, published methods, cited in the
analytics repo. Don't expect identical numbers to what their app shows.
Expand Down Expand Up @@ -143,9 +144,10 @@ shortcuts, a smart alarm that buzzes the band.
against a lab, don't treat any of it as a diagnosis.
- Not on the App Store or Play Store yet. iOS is a public TestFlight beta, which is a
normal install but still a beta; Android is an APK straight off Releases.
- WHOOP 5.0 / MG support is in progress and **experimental** — the band is detected and
spoken to, but it hasn't been validated against real 5.0 hardware. WHOOP 4.0 is the
only one that's actually tested.
- WHOOP 5.0 / MG support is **experimental**. Both pair, sync and decode, and the work is
checked against real records off real bands — but 4.0 is the one I wear every day, so
it's the one that gets found out when it breaks. Expect rough edges on 5 and MG, and
open an issue when you hit one.

## Run it

Expand Down
11 changes: 6 additions & 5 deletions docs/privacy.html
Original file line number Diff line number Diff line change
Expand Up @@ -68,9 +68,10 @@ <h2>Anonymous diagnostics</h2>

<h2>Barcode lookup for food logging</h2>
<p>The food log can read a barcode with the camera and fill in the nutrition
figures for you. Doing that means asking a database, so it is <strong>off
until you turn it on</strong>, and the App asks you before the first
lookup ever happens &mdash; not after.</p>
figures for you. Doing that means asking a database, so it is <strong>on
by default</strong>: what leaves is a number the manufacturer printed on
the packet, and nothing about you goes with it. Turn it off and the scan
stops asking anybody anything.</p>
<ul>
<li><strong>What is sent is the barcode.</strong> It goes to
openfoodfacts.org, the free and open food database. Nothing about you,
Expand Down Expand Up @@ -164,8 +165,8 @@ <h2>Your controls</h2>
integration at any time in Settings if you'd previously turned them on. If
you explicitly installed a GitHub release and enabled health data
contribution, you can disable that feature at any time from the app's
settings. Barcode lookup for the food log is off by default and can be
turned off again at any time in Settings &rsaquo; Privacy &rsaquo;
settings. Barcode lookup for the food log is on by default and can be
turned off at any time in Settings &rsaquo; Privacy &rsaquo;
&ldquo;Look barcodes up online&rdquo;.</p>

<p>Uninstalling the App deletes all of your locally stored data immediately.
Expand Down
14 changes: 7 additions & 7 deletions ios/Podfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,9 @@ PODS:
- Flutter
- home_widget (0.0.1):
- Flutter
- mobile_scanner (7.0.0):
- Flutter
- FlutterMacOS
- nanopb (3.30910.0):
- nanopb/decode (= 3.30910.0)
- nanopb/encode (= 3.30910.0)
Expand All @@ -232,9 +235,6 @@ PODS:
- SwiftyGif (5.4.5)
- url_launcher_ios (0.0.1):
- Flutter
- video_player_avfoundation (0.0.1):
- Flutter
- FlutterMacOS
- workmanager_apple (0.0.1):
- Flutter

Expand All @@ -255,12 +255,12 @@ DEPENDENCIES:
- geolocator_apple (from `.symlinks/plugins/geolocator_apple/darwin`)
- health (from `.symlinks/plugins/health/ios`)
- home_widget (from `.symlinks/plugins/home_widget/ios`)
- mobile_scanner (from `.symlinks/plugins/mobile_scanner/darwin`)
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
- share_plus (from `.symlinks/plugins/share_plus/ios`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
- sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
- video_player_avfoundation (from `.symlinks/plugins/video_player_avfoundation/darwin`)
- workmanager_apple (from `.symlinks/plugins/workmanager_apple/ios`)

SPEC REPOS:
Expand Down Expand Up @@ -323,6 +323,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/health/ios"
home_widget:
:path: ".symlinks/plugins/home_widget/ios"
mobile_scanner:
:path: ".symlinks/plugins/mobile_scanner/darwin"
package_info_plus:
:path: ".symlinks/plugins/package_info_plus/ios"
share_plus:
Expand All @@ -333,8 +335,6 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/sqflite_darwin/darwin"
url_launcher_ios:
:path: ".symlinks/plugins/url_launcher_ios/ios"
video_player_avfoundation:
:path: ".symlinks/plugins/video_player_avfoundation/darwin"
workmanager_apple:
:path: ".symlinks/plugins/workmanager_apple/ios"

Expand Down Expand Up @@ -374,6 +374,7 @@ SPEC CHECKSUMS:
GoogleUtilities: 766ace00c6b10d8148408f329d10c4f051931850
health: a4ddeac72091000e94776864d0028f6be31ec7a5
home_widget: f169fc41fd807b4d46ab6615dc44d62adbf9f64f
mobile_scanner: 9157936403f5a0644ca3779a38ff8404c5434a93
nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
PromisesObjC: 752c3227f599e3467650e47ea36f433eeb10c273
Expand All @@ -384,7 +385,6 @@ SPEC CHECKSUMS:
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
video_player_avfoundation: 3453f792138786248960ca029747fcd9f318ef52
workmanager_apple: 904529ae31e97fc5be632cf628507652294a0778

PODFILE CHECKSUM: b50997058227f33b81189532a9f3fc5007ec070b
Expand Down
28 changes: 16 additions & 12 deletions lib/ai/briefing_engine.dart
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import '../coach/coach_config.dart';
import '../coach/coach_engine.dart';
import '../data/day_label.dart';
import '../data/local_repository.dart';
import '../ui2/screens/home_screen.dart' as ring show readinessBand;
import 'briefing.dart';
import 'nightly_sweep.dart';

Expand Down Expand Up @@ -224,18 +225,21 @@ String partOfDay(DateTime now) {
/// and can contradict the score itself (a 16/100 read as "strong overnight
/// recovery"). The band is declared authoritative in the system prompt.
///
/// THE single source of truth for readiness-score banding — also used by
/// the Today ring's status word (`TodayVitals._orbitHero` in
/// today_screen.dart maps good/moderate/low → Push/Focus/Recover).
/// These cuts (40/66) MUST match the ring's own thresholds: a briefing band
/// computed from different cuts than the ring's word is exactly the
/// tone-vs-score contradiction this function exists to prevent, just moved
/// from "sub-metrics vs score" to "briefing vs ring".
String readinessBand(num v) {
if (v < 40) return 'low';
if (v < 66) return 'moderate';
return 'good';
}
/// DERIVED FROM THE RING, never re-declared. It used to carry its own 40/66
/// cuts with a comment insisting they match the ring's — and then #250 moved
/// the ring to the score's own quantiles (26/37/61) and left these behind. A
/// 61 was "Good to go" on Home and "moderate" in the briefing on the same
/// morning: the tone-vs-score contradiction this function exists to prevent,
/// arrived from the one direction the comment could not police.
///
/// So there is one classifier ([readinessBand] in home_screen.dart) and this
/// is a PRESENTATION of it: four tiers folded to the three words the prompt
/// speaks, with both warning tiers reading "low".
String readinessBand(num v) => switch (ring.readinessBand(v).tier) {
3 => 'good',
2 => 'moderate',
_ => 'low',
};

/// The nightly sweep's rules.
///
Expand Down
17 changes: 11 additions & 6 deletions lib/app.dart
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import 'ui2/screens/what_changed.dart';
import 'ui2/screens/health_screen.dart';
import 'ui2/screens/home_screen.dart';
import 'ui2/screens/journal_compose.dart';
import 'ui2/screens/log_workout.dart';
import 'ui2/screens/nutrition_screen.dart';
import 'ui2/screens/wellness_screen.dart';
import 'ui2/screens/workout_screen.dart';
Expand Down Expand Up @@ -337,13 +338,14 @@ ShellDomain domainForRoute(String route) => switch (route) {
/// The focused screen a deep link pushes on top of its domain, when one
/// exists. Null means the domain itself is the destination.
///
/// One route still resolves to null and should not: `/workouts/suggestion`
/// ("Tap to log it" has nothing to tap through to — nothing reads
/// `workout_suggestions`). It is recorded in the sweep; the fix is to stop
/// making the promise, not to route it somewhere plausible.
/// `/workouts/suggestion` used to be in that list, and it was the one route
/// where the fallback was a broken promise: "Tap to log it" landed on the
/// plain Workouts tab, because the screen that could log it was deleted with
/// `lib/ui/workouts/` and nothing read `workout_suggestions`. There is a
/// destination again, and confirming on it writes a real session.
///
/// `/ai/*` used to be in that list. It now lands on the briefing itself, which
/// also carries the exact snapshot that was sent to produce it.
/// `/ai/*` used to be in that list too. It now lands on the briefing itself,
/// which also carries the exact snapshot that was sent to produce it.
Widget? screenForRoute(String route) => switch (route) {
kRouteAiMorning =>
const AiBriefingScreen(period: BriefingPeriod.morning),
Expand All @@ -357,6 +359,9 @@ Widget? screenForRoute(String route) => switch (route) {
// which is how the tile that everybody actually used stayed add-only for
// so long — the thing that could clear a value was behind a notification.
kRouteWater => const NutritionScreen(),
// The detected bout, with the three answers to it: log it, adjust the
// times first, or say it never happened.
kRouteWorkoutSuggestion => const WorkoutSuggestionScreen(),
// Battery, band and sources all live behind this one.
kRouteProfile => const ProfileHome(),
// The weekly recap used to land on the Health tab and push nothing,
Expand Down
6 changes: 6 additions & 0 deletions lib/coach/coach_actions.dart
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import '../data/journal_fields.dart';
import '../data/local_repository.dart';
import '../data/med_store.dart';
import '../data/nutrition_store.dart';
import '../health/health_export.dart';

/// Raised when the model's arguments cannot be honoured. The message goes back
/// into the transcript so the model can correct itself rather than retrying the
Expand Down Expand Up @@ -264,6 +265,11 @@ class CoachActions {
endTs: startTs + mins * 60,
type: type,
);
// Every other write path exports; without this a workout logged through
// the coach reached the health store only if the next day-result pass
// happened to sweep it up (#130). The seam checks `healthSyncEnabled`
// itself, so this is a no-op with the switch off, and it never throws.
await HealthExporter.exportWorkoutId(r['workout_id'] as String?);
return jsonEncode({'saved': true, 'date': d, 'type': type, ...r});
} catch (e) {
// The repo rejects overlaps, futures and absurd durations. Hand the
Expand Down
Loading
Loading