Developer Guide & Toolchain
Prerequisites, FVM setup, worktree-per-task workflow, code generation, and running ChessSRS locally.
Setting up the development environment
ChessSRS Environment: See the repo root CLAUDE.md and AGENTS.md for the active toolchain and operating instructions. This file contains inherited setup instructions for mobile platforms.
The following instructions outline how to set up your development environment for starting development on Lichess mobile.
If you get stuck during the installation process the most suitable place to seek help is the #lichess-dev-onboarding channel on Discord (https://discord.gg/lichess).
Installing Flutter
This project uses the latest Flutter stable release. The minimum required version is defined in pubspec.yaml.
- Follow the flutter install guide. This project is meant to run on iOS and Android, so you need to follow the "Platform setup" section of that guide to install the iOS and/or Android platform. Make sure to install the latest Flutter stable release.
Installing on Linux using `snapd` might cause some [problems](https://github.com/lichess-org/mobile/issues/123) building stockfish. Installing flutter manually is a known workaround.
- Ensure Flutter is correctly configured by running
flutter doctor
Note that this application is not meant to be run on web platform.
Flutter Version Management
If you want to use FVM to manage your Flutter versions effectively, please consult the FVM (Flutter Version Management) guide for comprehensive instructions on installing Flutter on your specific operating system.
This project is currently using FVM 3.x.
Pro Tip: Remember to prepend the 'flutter' prefix when using FVM commands, like this: fvm flutter [command].
Lila Server
By default, the app will target the Lichess dev server, so you can start developing without setting up a local server.
During development, you may need a local lila (lichess server scala app) instance to work on this project.
If you work with a local lila, you will also need to setup lila-ws (websocket server).
lila-gitpod
The fastest and most straight-forward way to run your own lila is lila-gitpod.
lila-docker
If you have Docker installed on your system, you will probably prefer to use lila-docker.
Local lila server (manual installation)
Instructions to install both lila and lila-ws locally can be found in the lila wiki.
Setting up a device
Real device
Android
To use a real device you will need to enable developer options on it and have adb installed on your computer.
Once that done, you can connect your device either with USB or with Wi-Fi.
Connect with USB
Enable USB debugging and connect your phone with USB.
Run adb devices to check that your device is connected.
Connect with Wi-Fi
On your Android phone:
- Connect your phone to the same wifi network as your host machine
- In Developer Options, toggle Wireless Debugging to ON
- Tap "Wireless Debugging" to enter its menu
- Select Pair device with pairing code
On your computer:
- Pair your phone with
adb pair [phone_ip_address]:[pair_port] [pairing_code]. - Connect your phone with
adb connect [phone_ip_address]:[connection_port]. Your phone IP address and connection port should be written on the wireless debugging menu. - Check that your device is connected with
adb devices
Emulators
iOS
Simply follow the instructions in the flutter guide to install Xcode and the iOS simulator.
Android
Troubleshooting
If Chrome crashes, it is likely you need to disable Vulkan in emulator settings:
$ emulator -avd <YourAVD> -feature -VulkanYou can read more about this issue here.
If you cannot access internet you can try launching the emulator with a DNS address:
$ emulator -avd <YourAVD> -dns-server 1.1.1.1If you are on Linux it seems that the emulator will try to read
/etc/resolv.confto find DNS servers. You might want to check that your/etc/resolv.confis configured properly rather than using the emulatordns-serverflag first. If you use systemd-resolved, the recommended mode of handling/etc/resolv.confis to symlink/etc/resolv.confto/run/systemd/resolve/stub-resolv.conf.If you experience high lags or freezes, check the memory settings and be sure to enable hardware acceleration (
-gpu host).Also disabling the snapshot storage may help:
$ emulator -avd <YourAVD> -no-snapshot-load -no-snapstorage -no-snapshot -no-snapshot-saveIf you use Wayland, the emulator will not work. You can try to use Xwayland instead. The issue is tracked here.
Code generation
This project uses code generation to generate data classes with freezed among other things.
We don't commit generated code to the repository, so you need to run it before anything else otherwise the project won't compile:
flutter pub get
dart run build_runner build
While developing you can use the watch command:
dart run build_runner watch
Static analysis
Flutter comes with an analyze command to run static analysis. While developing you can run:
flutter analyze --watch
It will run analysis continuously, watching the filesystem for changes. It is important to always check for analysis errors.
Run
Use the flutter run command to run the app on an emulator or a real device. If you need to change the lichess host you can do it like so:
flutter run \
--dart-define=LICHESS_HOST=lichess.org \
--dart-define=LICHESS_WS_HOST=socket.lichess.org
Do not use any scheme (https:// or ws://) in url in host, since it's already handled by URI helper method
If you use the production server, note that you will not be able to log in.
Android
When using lila-gitpod
To access the lila instance from your device, you will need to make the port 8080 public. You can do so by running this command in your Gitpod workspace:
./lila-docker public
Then you can run the application with:
flutter run \
--dart-define=LICHESS_HOST=[lila_gitpod_host] \
--dart-define=LICHESS_WS_HOST=[lila_gitpod_host]
To find the host of your lila Gitpod instance, use the command gp url 8080. Just be careful to not include the full url in the hosts variables, you need to remove the https:// from the url.
When using lila-docker
When using lila-docker, the easiest solution is to use the adb reverse command:
adb reverse tcp:8080 tcp:8080
You can then run the app with the following hosts:
flutter run \
--dart-define=LICHESS_HOST=localhost:8080 \
--dart-define=LICHESS_WS_HOST=localhost:8080
Another option is to use the ./lila-docker hostname command and select your computer's IP address.
You can then run the app with the following hosts:
flutter run \
--dart-define=LICHESS_HOST=[your_computer_ip]:8080 \
--dart-define=LICHESS_WS_HOST=[your_computer_ip]:8080
When using a manually installed lila server
If you are working with a local lila server, you need to map some ports from the remote device to the local lila server.
Once your device is available, you can do this by running the following command (you can check if your device is available with adb devices):
adb reverse tcp:9663 tcp:9663
adb reverse tcp:9664 tcp:9664
This will allow the app to communicate with the local server. 9663 is for http
and 9664 is for websocket.
To run the application with the local server, you can use the following command:
flutter run \
--dart-define=LICHESS_HOST=localhost:9663 \
--dart-define=LICHESS_WS_HOST=localhost:9664
The ports above are the default ones for lila, if you have changed them, you
will need to adjust the commands accordingly.
Logging
dart devtools
Then run the app with the flutter run command above, and a link to the logging page will be printed in the console.
Repository Conventions & Style (CLAUDE.md)
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Project Overview
Lichess Mobile is a Flutter-based mobile application (iOS/Android) for lichess.org. The app uses:
- Flutter: Cross-platform UI framework (Flutter 3.38.0+, Dart 3.10.0+)
- Riverpod: State management with providers
- Freezed: Immutable data classes
- Code generation: For data classes, JSON serialization, and localization
- Firebase: Crashlytics and messaging
- Stockfish: Chess engine integration via
multistockfishpackage
Development Setup
Initial Setup
## Install dependencies
flutter pub get
## Generate code (required before first run)
dart run build_runner build
## For continuous development
dart run build_runner watch &
flutter analyze --watch &
Running the App
## Run on all devices (uses lichess.dev server by default)
flutter run -d all
## Run with custom server
flutter run \
--dart-define=LICHESS_HOST=localhost:8080 \
--dart-define=LICHESS_WS_HOST=localhost:8080
Note: Do not include scheme (https:// or ws://) in host values.
For Android with local lila-docker
## Map ports
adb reverse tcp:8080 tcp:8080
## Run app
flutter run --dart-define=LICHESS_HOST=localhost:8080 --dart-define=LICHESS_WS_HOST=localhost:8080
Testing & Quality
Run Tests
## All tests
flutter test
## Single test file
flutter test test/model/engine/engine_test.dart
## Specific test
flutter test test/model/engine/engine_test.dart --name "test name"
Testing Strategy: Prefer HTTP Mocking Over Provider Overrides
Always mock at the HTTP layer (override httpClientFactoryProvider) rather than overriding Riverpod providers directly. Reasons:
autoDispose+ref.readinteraction: Many providers areFutureProvider.autoDisposeand useref.withClientCacheForwhich callskeepAlive()to prevent premature disposal. Overriding the provider directly bypasseskeepAlive(), so the provider can be disposed before its future resolves — causing silent test failures where navigation never happens.Tests provider logic, not mocks: Most providers contain real logic (caching, fallback, data transformation) that should be exercised in tests. Replacing a provider with
(_) async => fakeValueskips all that logic.
Pattern to use:
overrides: {
httpClientFactoryProvider: httpClientFactoryProvider.overrideWith((ref) {
return FakeHttpClientFactory(
() => MockClient((request) async {
if (request.url.path == '/api/puzzle/daily') {
return http.Response(mockDailyPuzzleResponse, 200);
}
return http.Response('', 404);
}),
);
}),
},
Direct provider overrides are acceptable for non-network providers (repositories backed by mocks, services with no HTTP, etc.) where the provider has no keepAlive dependency and the override doesn't skip meaningful logic.
Chessboard Testing Patterns
Since chessground v10, pieces and highlights are rendered by CustomPainters, not as individual widgets. Do not use find.byKey(Key('e2-whitepawn')) or find.byKey(ValueKey('${sq}-highlight')) — those keys no longer exist.
Use the helpers in test/test_helpers.dart instead:
// Check pieces on any Chessboard (interactive or Chessboard.fixed)
getBoardPieces(tester) // Map<Square, Piece>
boardHasPiece(tester, Square.f3, Piece.whiteKnight) // bool
// Check premove highlight
boardHasPremove(tester, move) // bool
// Tap/move at a board square
squareOffset(square, tester.getRect(find.byType(Chessboard)))
For ChessboardEditor (board editor screen), read pieces directly from the widget:
tester.widget<ChessboardEditor>(find.byType(ChessboardEditor)).pieces
Analysis Rules (CRITICAL)
Always run flutter analyze on every file you edit, including test files, before finishing.
Two rules the analyzer enforces that are easy to miss:
constconstructors: useconst(notfinal) when constructing a const-capable class. The analyzer will flagprefer_const_constructors. This applies everywhere, including test files. When the enclosing expression is notconst(e.g. a non-const record or class), constructor calls inside it still need their own explicitconst— e.g.(time: const Duration(minutes: 3), increment: const Duration(seconds: 2)).- No leading underscores for local identifiers: local variables and functions must not start with
_. Reserve_for library-private top-level or class members.
Code Quality Checks
## Static analysis
flutter analyze
## Format check (files to format)
dart format --output=none --set-exit-if-changed $(find lib/src -name "*.dart" -not \( -name "*.*freezed.dart" -o -name "*.*g.dart" -o -name "*lichess_icons.dart" \) )
dart format --output=none --set-exit-if-changed $(find test -name "*.dart" -not \( -name "*.*freezed.dart" -o -name "*.*g.dart" \) )
## Format all code
dart format lib/src test
Formatting Rules (CRITICAL)
Always run dart format on every file you edit before finishing. CI will fail if formatting is wrong.
dart format path/to/file.dart
The formatter is configured via analysis_options.yaml (formatter: page_width: 100) and dart format picks this up automatically. Key rules enforced by the formatter:
- Page width: 100 characters — lines exceeding 100 chars will be reflowed
- Trailing commas drive formatting: a trailing comma after the last argument/parameter forces the formatter to expand the list to one-item-per-line; omitting it allows the formatter to keep items on one line if they fit within 100 chars
- Do not manually wrap lines — let the formatter decide based on trailing commas and line length; hand-wrapping without trailing commas will be reformatted by the tool
- The formatter may reformat code you didn't touch in the same expression if you change surrounding structure
Comments are the exception: the formatter never reflows them, so they are yours to wrap.
Wrap comments and doc comments (// and ///) to the same 100 character page width, not to
80. dart format leaves prose untouched, so a comment wrapped at 80 will simply stay that way
and look inconsistent with the code around it. Fill each line before wrapping to the next.
Translations (i18n)
CRITICAL: Never manually edit lib/l10n/app_*.arb files - they are generated.
Adding New Translations
- Edit
translation/source/mobile.xmlfor mobile-specific strings - Regenerate everything:
./scripts/gen-translations.sh
This runs gen-arb.mjs, flutter gen-l10n, and gen-widget-strings.mjs in order.
Mobile-specific translations get a mobile prefix (e.g., "foo" becomes mobileFoo in Dart).
Architecture
Directory Structure
lib/src/
├── model/ # Business logic, state management (Riverpod providers)
│ ├── account/
│ ├── analysis/
│ ├── auth/
│ ├── challenge/
│ ├── common/ # Shared models and utilities
│ ├── engine/ # Stockfish integration
│ ├── game/
│ ├── puzzle/
│ ├── settings/
│ └── ...
├── view/ # UI screens and pages
│ ├── account/
│ ├── analysis/
│ ├── game/
│ ├── home/
│ ├── play/
│ ├── puzzle/
│ ├── settings/
│ └── ...
├── widgets/ # Reusable UI components
├── network/ # HTTP client, WebSocket, connectivity
├── utils/ # Helper functions and utilities
├── styles/ # Theme, colors, icons
├── db/ # Local database (sqflite)
├── app.dart # Main app widget
├── binding.dart # Plugin/API abstraction layer
└── constants.dart # App-wide constants
Key Architectural Patterns
State Management: Riverpod providers throughout lib/src/model/. Controllers, repositories, and services are implemented as providers. State is immutable and managed with Freezed data classes.
Riverpod version: 3.x — API differences from 2.x (CRITICAL)
This project uses flutter_riverpod 3.x / riverpod 3.x. Several APIs changed from 2.x:
AsyncValue.value(notvalueOrNull): In 3.x,AsyncValue<T>.valuereturnsT?(null while loading/error).valueOrNullno longer exists.ProviderListenableis not exported: Do not useProviderListenable<T>as a type annotation — it is an internal interface. The return type of.select()is also internal (_ProviderSelector).ProviderListenableSelectis exported but is an extension onProviderListenable, not a class — it cannot be used as a type.- When you need to pass a provider or select result to
ref.watch/ref.read/ref.listenManual, just pass it directly without naming the type. If a return type annotation is required (e.g. an abstract method), use two concrete methods (readCurrentState()+listenToStateChanges()) instead of trying to name the provider type.
- When you need to pass a provider or select result to
ref.listenManualtype inference: When the selected type is nullable (e.g.provider.select((v) => v.value)→T?), Dart cannot inferStateTbecause the callback signature isvoid Function(StateT?, StateT). Always add an explicit type:ref.listenManual<T?>(provider.select(...), listener).listenManualinConsumerState: Subscriptions set up ininitState()viaref.listenManualare automatically cancelled when the widget is disposed. No need to store or cancel manually.
Binding Layer: LichessBinding (in binding.dart) provides a testable abstraction for plugins and external APIs:
- SharedPreferences
- Firebase (messaging, crashlytics)
- Stockfish factory
Use AppLichessBinding.ensureInitialized() in production, TestLichessBinding in tests.
Network Layer:
- HTTP:
lib/src/network/http.dart- Platform-specific clients (Cronet for Android, Cupertino for iOS) with authentication, caching, and retry logic - WebSocket:
lib/src/network/socket.dart- Handles ping/pong, message acks, auto-reconnection, event versioning - Helper:
lichessUri(path, queryParams)for HTTP,lichessWSUri(path, queryParams)for WebSocket
Services: Long-running background services initialized in app.dart:
AccountService,NotificationService,MessageService,ChallengeService,CorrespondenceService- Start in
_AppState.initState()
Navigation: Uses Flutter's Navigator with custom route resolution via app_links.dart for deep linking.
Dart Event Loop Execution Model
Understanding Dart's event loop is critical for this codebase due to heavy async operations (network requests, WebSocket communication, Stockfish engine interaction).
Event Loop Basics
Dart is single-threaded and uses an event loop with two queues:
Microtask Queue (higher priority)
- Executed before the event queue
- Scheduled with
scheduleMicrotask()or viaFuturecompletions - Used internally by
Future.then(),async/await
Event Queue (lower priority)
- I/O events, timers, user interactions
- Scheduled with
Future(),Timer,Streamevents - UI rendering happens between event queue items
Execution order:
1. Execute current synchronous code
2. Process ALL microtasks (until microtask queue is empty)
3. Process ONE event from event queue
4. Repeat from step 2
Async/Await Behavior
// This does NOT block the event loop
Future<void> fetchData() async {
final response = await http.get(uri); // Yields to event loop
// Resumes here when response completes
processData(response);
}
When await is encountered:
- Current function execution pauses
- Control returns to event loop
- Function resumes as a microtask when Future completes
Common Patterns in This Codebase
Riverpod AsyncNotifier: State updates are async but don't block UI:
class MyController extends AsyncNotifier<Data> {
@override
Future<Data> build() async {
// Fetches async, UI shows loading state
return await repository.getData();
}
}
WebSocket Message Handling (see lib/src/network/socket.dart):
- Messages arrive as events
- Processed in event queue
- Microtasks schedule state updates
Stockfish Engine Communication:
- Engine runs in isolate (separate event loop)
- Communication via
SendPort/ReceivePort(event queue)
Important Gotchas
Microtask Queue Starvation: Never create infinite microtask loops - they block the event queue and freeze the UI:
// BAD - Starves event queue
void badLoop() {
scheduleMicrotask(() {
doWork();
badLoop(); // Immediately schedules another microtask
});
}
// GOOD - Allows event queue processing
void goodLoop() {
Future(() { // Uses event queue
doWork();
goodLoop();
});
}
Future vs Future.microtask:
Future(callback)→ event queueFuture.microtask(callback)→ microtask queue- Prefer event queue for non-critical work
Stream Subscriptions: Always cancel to prevent memory leaks:
// In StatefulWidget or Riverpod
final subscription = stream.listen(onData);
@override
void dispose() {
subscription.cancel(); // Critical!
super.dispose();
}
Testing with fake_async: Use FakeAsync for tests involving timers and microtasks to control time progression.
Coding Standards
Immutability (Required)
All data structures must be immutable (all fields final or late final):
- Use Freezed for data classes
- Use fast_immutable_collections for collections in public APIs
- Standard Dart collections (
List,Map) are forbidden in public APIs but allowed in local scopes
Strong Typing
Prefer strong types over primitives (e.g., Duration instead of int).
Dot Shorthand Syntax (Dart 3.10+)
Use dot shorthand syntax (.foo) to write more concise code when the type can be inferred from context. This is especially useful for enums, named constructors, and static members.
// Enums - use shorthand
Status current = .running; // Good
Status current = Status.running; // Verbose
// Switch statements
switch (status) {
case .running: ...
case .stopped: ...
}
// Named constructors
Point p = .origin(); // Good
Point p = Point.origin(); // Verbose
// Widget properties
MainAxisAlignment: .center, // Good
MainAxisAlignment: MainAxisAlignment.center, // Verbose
// Equality checks (shorthand on right side)
if (color == .red) ... // Good
if (.red == color) ... // Won't work
Note: Shorthand requires clear context type inference and cannot start expression statements.
Functional Style
Prefer functional constructs over imperative:
// Good
return [
if (check) Text('conditional'),
for (el in items) Text(el.name),
];
// Bad
final widgets = <Widget>[];
if (check) widgets.add(Text('conditional'));
for (el in items) widgets.add(Text(el.name));
Widget Guidelines
- Avoid functions returning widgets (use
StatelessWidgetfor reusables) - Don't create private widgets used only once - inline them
- Write reusable widgets as classes even if single-screen scope
Analysis
- Strict mode enabled:
strict-casts,strict-inference,strict-raw-types - Use single quotes for strings
- Always use package imports (no relative imports)
- Page width: 100 characters
- Generated files (
*.g.dart,*.freezed.dart) are excluded from analysis
Code Generation
This project heavily uses code generation. Always run dart run build_runner build (or watch) after:
- Modifying Freezed classes
- Adding JSON serialization
- Changing models with code generation annotations
Generated files are NOT committed to git.
Common Gotchas
- Don't edit generated files: Anything ending in
.g.dart,.freezed.dart, or inlib/l10n/ - Translations: Start with hardcoded English text for new features. Add translations after the feature is stable and in use
- Error messages: Don't translate non-critical error messages (e.g., "could not load XY")
- Brand names: Don't translate names like "Puzzle Storm" or "Puzzle Streak"
- FVM users: Remember to prefix commands with
fvm(e.g.,fvm flutter test)
iOS Home Screen Widgets (WidgetKit Extension)
The app includes a native iOS WidgetKit extension (ios/LichessWidgets/) providing home screen widgets. See ios/EXTENSIONS.md for contributor setup instructions (requires Apple Developer account configuration).
Architecture
LichessWidgetsBundle.swift—@mainentry point registering all 4 widgets.LichessAppGroup.swift— Reads sharedUserDefaultsfrom App Groupgroup.org.chesssrs.app.LichessWidgets:lichessHost,boardTheme,pieceSet,isKidMode
Deeplinks.swift— Custom URI scheme encoding for opening URLs in the in-app browser.- Dependencies: WidgetKit, ChessgroundAssets (Swift Package, shared with Dart), FeedKit, XMLKit.
Flutter Integration
The Flutter app (via home_widget package) writes to the shared App Group from app.dart:
lichessHost— server URLboardTheme/pieceSet— board appearance (triggersDailyPuzzleWidgetreload)isKidMode— hides blog widgets when active (triggers blog widget reload)
When modifying widget-related settings or board theme/piece set preferences in Dart, ensure the corresponding HomeWidget.saveWidgetData call and HomeWidget.updateWidget are kept in sync in lib/src/app.dart.
Widget UI strings are translated via ios/LichessWidgets/Localizable.xcstrings (a String Catalog). To add a new translatable string, register it under WIDGET_KEYS in scripts/gen-widget-strings.mjs and run ./scripts/gen-translations.sh.
Code Signing
The extension has its own bundle ID (org.lichess.mobileV2.LichessWidgets) and App Group entitlement. fastlane sync_code_signing handles provisioning for both targets (see ios/fastlane/Matchfile).
Debugging
## Start DevTools for logging
dart devtools
## Then run app and follow printed link
flutter run
Agent Operating Guidelines & Authority Hierarchy (AGENTS.md)
Agent Operating Guidelines & Authority Hierarchy
0. Foundation context (read first)
This application is a fork of Lichess Mobile (GPL-3.0) rebuilt as a
local-first chess repertoire + spaced-repetition trainer. The previous
standalone implementation is archived at git tag legacy/pre-reset — it is
reference material only. Never resurrect old lib/ code because class names
look familiar; the domain contracts are specified in the Markdown documents
and must be reimplemented cleanly inside the Lichess Mobile architecture.
Before doing anything in the codebase, read:
ARCHITECTURE.md(layer boundaries, licensing constraints)CUT_PROPOSALS.md(what Lichess functionality is removed/kept, and status)docs/INTEGRATION_MAP.md(how Listudy/chessrs concepts are integrated)CLAUDE.mdin the repo root — the Lichess Mobile contributor guide (Riverpod 3.x patterns, Freezed, code generation, formatting, testing patterns). It remains authoritative for the foundation's conventions.
1. The Authority Hierarchy
PRODUCT.md
↓ (product vision, user journeys, functional & UX requirements)
MVP.md
↓ (current active scope, included/excluded capabilities)
ARCHITECTURE.md
↓ (Lichess foundation, layer boundaries, licensing)
QUALITY.md
↓ (non-negotiable engineering, domain, performance invariants)
TEST_STRATEGY.md
↓ (how invariants are mechanically proven)
IMPLEMENTATION_PLAN.md
↓ (phases, tasks, execution status)
AGENTS.md ← you are here
Rule: Code implements specifications. Code is not the specification. If requirements or architectural boundaries change, update the authoritative specification documents accordingly.
2. Development phases (beta-first)
The strategy is: foundation → minimal vertical slice → beta → feedback →
refinement → additional modules. See IMPLEMENTATION_PLAN.md for the live
phase status. The owner is the beta tester. Do not gold-plate before the core
review loop is in the owner's hands.
Do NOT start Listudy/chessrs integration work until the foundation is stable and the vertical slice exists (unless the task explicitly says otherwise).
3. The Development Loop
Every engineering task must follow:
1. SELECT READY TASK from IMPLEMENTATION_PLAN.md (dependencies complete).
2. READ THE SPEC: PRODUCT/MVP/ARCHITECTURE/QUALITY sections that apply.
3. DEFINE A SMALL CONTRACT: testable, incremental brick.
4. WRITE TESTS with the contract (dart test, widget test, or integration
test as appropriate).
5. IMPLEMENT minimally, following Lichess Mobile conventions (CLAUDE.md).
6. TARGETED VERIFICATION: run the specific test file — once, and on a budget
(see §3.1). The owner's machine heats up under repeated local runs; every
local run you skip is a run CI does for you on merge.
7. STATIC CHECK: `flutter analyze` on the files you touched.
The full suite is CI's job — see the note on ./verify below. Do not run
`./verify` as part of the inner loop.
8. RUNTIME VALIDATION (see §4) for anything user-visible.
9. FIX ROOT CAUSES; add permanent regression tests.
10. UPDATE IMPLEMENTATION_PLAN.md / spec docs if boundaries changed.
11. ATOMIC COMMIT matching repository conventions.
3.1 Local test budget (the machine is not a build server)
CI runs the full suite on every push. Local runs exist only to answer "does my change do what I claim" — one answer, minimum heat:
- Iterate with
fvm flutter test <file> --plain-name '<test name>': one test, not the file. The full file runs exactly once, at the end. - Never run neighbouring files, whole directories, or
./verifylocally to "be safe" — that safety is what the PR's CI run is for. If you have a concrete reason to suspect cross-file breakage, state it in the PR instead of running it. - The fail-on-old-code check (stash + rerun) counts as the second run of the file: pass run + fail run, then stop. No third run.
- One
flutter analyzeper task, on the files touched, after the last edit — not after every edit. - Never run two heavy commands at once (no test run beside build_runner, no
parallel worktrees verifying simultaneously). The per-worktree
build_runner build(~85s) is the one unavoidable cost; everything else is negotiable. fvm flutter run -d linuxfor runtime validation is exempt from the budget but stays bounded: launch, exercise, quit. No release builds, ever (§4).
Isolation: one worktree per task, main only by merged PR
More than one agent works in this repository at a time. Each task gets its own
git worktree and its own branch, so no two agents share a working tree. main
is updated only by merging a PR that CI has passed — never pushed to directly.
The repository is cassandre60/ChessSRS. Nothing else. A second repository,
cassandre60/chess-repertoire-srs, held all 195 PRs until 2026-10-07 and is now
an archived read-only fork. It was created four days later than ChessSRS and so
looked newer, which is how every PR kept landing in the wrong place. Nothing has
landed there since. Do not open a PR against it, do not push a branch to it, do
not clone it, and if a remote resolves to chess-repertoire-srs, fix it before
doing anything else.
## once per task, from anywhere
git remote set-url origin https://github.com/cassandre60/ChessSRS.git
git worktree add ../chesssrs-<slug> -b <type>/<area>-<slug> origin/main
cd ../chesssrs-<slug>
## required before anything will compile: generated files are gitignored, so they
## are per-worktree. Without this every test fails with "No such file or directory"
## on a *.freezed.dart or *.g.dart import. ~85s.
fvm flutter pub get
fvm dart run build_runner build --delete-conflicting-outputs
## ... work, stage BY NAME, commit, push
git push -u origin <branch>
gh pr create -R cassandre60/ChessSRS
## after the PR is green and merged
git worktree remove ../chesssrs-<slug> && git worktree prune
Branch names are <type>/<area>-<slug> — fix/review-redirects,
feat/design-tokens. Not agent1/…: the branch should describe the change, so
it reads the same whoever picks it up.
A docs-only PR gets no CI run at all, and that is deliberate. The workflow's
paths filter lists what the job actually reads — lib/, test/, the build and
lint configuration, assets/, scripts/ — and Markdown is not among them,
because no step in the job reads it. So for a change touching only .md files,
"CI is green" means no checks were reported, not checks passed. Say so in the
PR rather than leaving a reviewer to wonder whether it ran. If a docs change ever
does need a check, add a small dedicated workflow triggered on **.md; do not
widen the test job's filter just to make a doc edit look verified.
Four rules, each of which exists because breaking it has cost real work:
- Never run a git command that writes outside your own worktree. No
git -C <other-path> reset,checkout --,clean, orstash. Agit reset --hardorgit checkout -- .in a shared tree silently destroys every uncommitted change another agent has made, and there is no undo for it: unstaged content is never written to the object store, sogit fsckcannot recover it. This has happened here once already. - Never
git add -A. Stage by name. It is the same failure by another route, and it survives into a commit. - Never delete a worktree you did not create.
git worktree listfirst. - Check
git statusbefore and after anything that touches the tree, so you can tell what you changed from what someone else changed. - Never push to, merge, or close a branch or PR you did not create without
re-reading its live state first. More than one agent pushes here:
git fetch origin <branch>and compare tips before pushing (a rejected push means someone else moved it — integrate, don't force), and re-read the PR's state and head-sha immediately before merging or closing it. A reading minutes old has closed, merged, or moved under you before. Verified 2026-10-08: one rejected push, one close overridden by reopen, one merge raced, all on shared branches.
Isolation makes conflicts visible — two agents editing one file become a merge conflict instead of a silent overwrite. It does not make them agree. When two tasks touch the same subsystem, say so before starting; the worktree will not resolve a disagreement about what the code should do.
Recovery is faster than prevention, but it is not guaranteed. If another agent's uncommitted work is at risk, it exists only in their working tree. Commit early and often on your own branch, and take a filesystem snapshot if you have one.
Lichess Mobile conventions that always apply
- Riverpod 3.x (
.value, notvalueOrNull; noProviderListenabletype annotations). Prefer HTTP-layer mocking over provider overrides in tests. - Freezed + fast_immutable_collections for data classes; generated files are
never committed; run
dart run build_runner buildafter model changes. flutter analyzeon every edited file (including tests) — zero warnings.dart formatevery edited file (page width 100, fromanalysis_options.yaml— so runfvm flutter pub getfirst: with unresolved includes the formatter silently falls back to width 80 and rewrites hundreds of lines).- Package imports, single quotes, strict-casts/inference/raw-types.
- Translations: hardcoded English first; l10n pipeline only after stability.
Visual work
For anything touching layout, colour, typography, iconography, motion, or
component choice, follow design/docs/ (see design/README.md), not Lichess
Mobile's visual conventions in the inherited CLAUDE.md. Lichess Mobile
conventions still apply to non-visual engineering practices (testing,
architecture, commit hygiene) inherited via CLAUDE.md.
Screenshot evidence is required for visual PRs (this was already the rule; it
now also applies against the design package): capture the affected screens at
phone, tablet and desktop widths, light and dark, and compare them against
design/reference/index.html shown at the same sizes/themes.
4. Runtime validation is mandatory
Past sessions produced green ./verify runs while the real app had runtime
errors or looked nothing like the intended UI. Therefore, for every
user-visible milestone:
- The suite is green — from CI, or
./verifywhen working offline. - Launch the real application (Linux desktop or attached device) and manually exercise the affected feature.
- Visually inspect the UI. For UI work, screenshot/runtime inspection is required — "tests pass" is not evidence of "visual pass".
- Only then declare the milestone complete and record it in
IMPLEMENTATION_PLAN.md.
Record what step 2 cannot cover in docs/manual-test-log.md, in the same PR
that ships the change — not afterwards, when the context is gone. Sound and
haptics are the standing case: FakeSoundService returns Future.value(), so
every audio test in this repo passes identically whether the volume is at zero
or the audio session is dead. A green suite is not evidence that a speaker
produced anything.
Decisions engineering cannot make for the owner go in docs/open-questions.md
with what is known, what is not, and what is needed to settle it — rather than in
a PR comment or a chat that scrolls away. Measurement that needs no decision does
not belong there.
Commands (FVM-pinned toolchain):
fvm flutter pub get
dart run build_runner build # after model/codegen changes
fvm flutter analyze
fvm flutter test
fvm flutter run -d linux # runtime validation
./verify runs flutter analyze followed by flutter test over the whole
suite — around 1400 tests, with no path filter. That saturates the machine
and takes minutes, so it is a pre-push and milestone gate, not an
inner-loop step.
- Per change:
flutter analyzeon the files you touched, plus the one test file covering the change. That is the loop. - Per push / before declaring a milestone: let GitHub Actions do it. CI
runs
flutter teston every push and is the authority on whether the suite is green, so a local full-suite run before that push is duplicate work, not extra safety. Run./verifylocally only when CI is unavailable or you specifically need the answer before pushing. Wait on a run withgh run watch <id> --exit-status, not sleep-and-poll loops.
Say plainly in the commit message when a change was verified only by targeted tests, so nobody mistakes it for a full-suite result.
Release builds never run on a dev machine. flutter build appbundle/apk --release
(R8 + native engines) OOM-killed a 15GB desktop mid-build, and Gradle's Groovy compiler cannot
run on that machine's Java 27 at all. Release artifacts come only from the release-proof
workflow on GitHub runners (dispatch it, or touch its file in a PR). Never attempt a local
release build to "prove" anything — the proof already has a home, and the crash it risks is the
owner's working machine, not a throwaway VM.
5. Quality gates and invariants
QUALITY.mdinvariants are binding: pure domain layer, single chess representation (dartchess), local-first critical path, variation preservation, position identity, incremental persistence, deterministic clocks.TEST_STRATEGY.mddefines the enforcement matrix; update it when the enforcement mechanism changes (not just when tests move).- Old legacy tests (tag
legacy/pre-reset) are contract references: mine them for intended behavior, then translate valuable cases into new tests. Never copy old tests that merely encode the old architecture.
6. Feature-creep policy
Default answer to new features is no. No achievements, gamification, dashboards, social features, analytics, cloud sync, or accounts unless explicitly justified by the product specs or later beta feedback (clustered and reviewed first). When uncertain: prefer deleting unnecessary things.
7. Licensing discipline
- We are a GPL-3.0 fork: preserve
LICENSE/COPYING.mdand copyright notices whenever trimming Lichess code. - chessrs (GPL): attribution if adapting; prefer reimplementation.
- listudy (AGPL): behavior only, never code.
8. Chess-domain references
When solving PGN-tree, repertoire-training, or SRS problems, inspect the
reference repositories first (see docs/INTEGRATION_MAP.md for what to take
and what to ignore):
- https://github.com/ZackMurry/chessrs (SRS scheduling, review queues)
- https://github.com/ArneVogel/listudy (study trees, training variations)
Do not invent a third architecture when proven behavior exists. Also check
Lichess Mobile's own model/study/ and model/common/node.dart — the
foundation already contains study-tree and game-tree prior art.
9. Cut discipline (Phase 1 and beyond)
- Follow
CUT_PROPOSALS.md; do not silently make controversial cuts. - One subsystem per commit; after each cut: targeted tests + launch + smoke run. Run the full gate once at the end of a batch, not per cut.
- Trace dependencies before deleting: a removed feature may leave a reusable primitive (filter widget, avatar, sheet) that other survivors need.
- Keep GPL notices of any removed-origin code that still shares files.
10. Quality gates: rules for any agent or contributor working here
This repository is gated. Your job is to get a change accepted by the gates
honestly, not to make the gates go green. Before editing, read SPEC.md
(invariants with stable IDs), GATES.md (everything that can reject you),
GATES_PLAN.md (what is deliberately not gated), and .gates/.
- Classify your change (bugfix, perf, refactor, feature, test-only,
dependency, docs, gate-change, spec-change) and gather the evidence that
class requires — see
.github/PULL_REQUEST_TEMPLATE.mdandGATES.md. A bugfix starts with a test that fails on the current code. - Never edit the referee to get a pass:
SPEC.md,GATES.md,GATES_PLAN.md,QUALITY.md,TEST_STRATEGY.md,CODEOWNERS,.gates/,redteam/,scripts/gates*, CI config,analysis_options.yaml,pubspec.yaml, ratchet baselines,AGENTS.md/CLAUDE.md, the PR template. If you think a gate is wrong, stop and propose a separate gate-change PR with evidence — never bundle it with product code. - Never weaken a check: no deleted or loosened assertions, no skip/ignore/xfail, no new suppressions, no widened tolerances, no mocking the subject under test, no hardcoded expected values, no catch-all error handling. The tripwire (G08) diffs for exactly this.
- When a gate fails, fix the code. Reproduce with the printed command, and address the root cause. After 3 failed attempts on the same gate, stop and report what you learned instead of iterating blindly.
- Run
./scripts/gates.sh t1(needsBASE_REF, defaults toorigin/main) before pushing — after committing, because the diff-based gates readorigin/main...HEADand see nothing at all while your work is still in the working tree. A pre-commit run that reports G08 "clean" is not evidence; run the same command again on the committed branch. The full suite stays CI's job per §3.1. - New tests cite their invariant (
// SPEC INV-xxx.); G05 rejects orphaned invariants and unknown IDs. - Fixing a bug the gates missed? Follow
docs/ESCAPE_TO_GATE.md— regression test, SPEC invariant, red-team case, escapes-log row, in that order. The flywheel only turns if you turn it. - Report honestly: what you ran, what passed, what you could not run, and what remains unverified.
11. Releases: when to cut one (agent decides, owner doesn't)
The release workflow (.github/workflows/release.yml) is the executable
truth. Tags (git tag vX.Y.Z && git push origin vX.Y.Z) trigger automatic
build + publish (see docs/releases.md for full policy). The agent
evaluates after every merged green batch and cuts a release when ALL hold:
- Something the owner can feel changed (feature, fix, copy). Gate/CI/ refactor/test-only/docs-only deltas ride silently.
- The work's PR went in green (T1 + Unit tests) with evidence intact.
- Nothing known-broken is pending; no unverified claims in release notes.
Not worth releasing: invisible delta, same batch already tagged, or red tree.
Versioning: patch (x.y.Z) fixes/copy, minor (x.Y.0) features/settings.
Major stays 0 until owner declares otherwise. Watch Release workflow
to green; report download link. Tags publish — never push casually,
never move/delete a published tag.
Lessons Learned
Where the open threads live (2026-10-14)
Start here before asking what is outstanding. This section is the map; the files it names hold the detail.
| Thread | File | State |
|---|---|---|
| Anything needing an owner decision | docs/open-questions.md |
Q1-Q5 answered, Q6 fixed 2026-10-14 |
| Performance work | docs/performance-handoff.md |
mandate set, ~13 ms still unattributed |
| Why performance is blocked | docs/tasks/PERF-FIX-24-real-library-measurement.md |
the benchmark is unreliable; fix the instrument before optimising |
| Claims the suite cannot verify | docs/manual-test-log.md |
one open item, needs a human |
| Work queue | docs/tasks/README.md |
claims marked in the brief banners |
Two things that are not outstanding and should not be reopened without new
information: the review move sound (#93) was confirmed audible by the owner, and
getDueReviewStates was deleted on the owner's authorisation (#123).
[2026-10-07, Space Bunny Free]
./scripts/gates.sh t1run before committing is a green light that means nothing: the diff-based gates readorigin/main...HEAD, so while the work is uncommitted HEAD is still the base and the diff is empty. Two reskin PRs (#186, #187) both reportedno test-weakening patterns foundlocally and both failed CI's G08 with 8 and 13 findings respectively. Run the gate after the commit, or runpython3 scripts/gates/test_weakening_check.py --base origin/maindirectly, which reads the same committed range and cannot be fooled by a dirty tree. Verified by:gates.sh t1printing "clean" with six modified files unstaged, then the same script run directly on the identical tree printing the findings CI did.[2026-09-25, Space Bunny Free] The full suite is a pre-push gate, not a per-commit one; this file used to mandate the opposite, and an agent following it saturated the machine on every change. Do not restore per-commit
./verifywithout measuring it. Verified by: the owner reporting 100% CPU and full-speed fans after repeated full-suite runs.[2026-09-26, Space Bunny Free] A shared working tree is not survivable with more than one agent:
git reset --hardrun to sync with a squash-merge destroyed 17 files of another agent's uncommitted work, ~10 of it recoverable from no ref at all. Unstaged content is never written to the object store, sogit fsckcannot bring it back — recovery depends on editor history or a filesystem snapshot. Hence the worktree-per-task rule in §3. Verified by:git statusgoing from 23 dirty files to 7, and bygit diff --quietagainst every branch showing the lost files identical tomain.[2026-09-26, Space Bunny Free] A fresh worktree cannot run a single test until
dart run build_runner buildhas run in it —*.freezed.dartand*.g.dartare gitignored, so codegen output is per-worktree, not shared. The symptom is a wall ofNo such file or directoryon generated imports that looks like a broken checkout. Costs ~85s per new worktree. Verified by:flutter testfailing in a new worktree, then passing after build_runner.[2026-09-26, Space Bunny Free] Audit's "High confidence" ratings do not predict whether a finding is a live defect in this fork. Of the findings checked line-by-line against upstream, five collapsed: M23 was 1 of 3 claims in scope, M4 was already fixed, H1's evidence was stale, M18 was not a defect (
ref.onDisposefires on rebuild as well as on disposal, which is what clears the accumulator), and M17 was inherited unchanged and unreachable because nothing writes the table it reads. What correlated with a real, in-scope defect was file-level evidence pointing at fork-introduced code. Verified by: CI logs andgit diff upstream/mainon each claim before acting.[2026-09-26, Space Bunny Free]
makeTestProviderScopestubspreloadedDataProvider, which is the only code that reads the stored token and fires/api/token/teston startup. So no app-level test can ever observe the token check: the request cannot be issued by anything. A test asserting it was skipped for days and blamed on a CI/machine difference; it failed identically locally. Before debugging a missing startup request, check whether the harness replaced the provider that makes it. Verified by: counting HTTP client constructions in the test — the app built one, for FCM, and none for the token check. Full account indocs/audit-disposition.md.[2026-09-27, Space Bunny Free] A test earns its place only if it fails when the code is wrong, not when it is merely different. The qualifying cases are: a defect just fixed, a contract that has already bitten us (44px targets, a tap actually reaching its row, a viewport not clipping its content), or an invariant in
QUALITY.md. Of 18 tests added across the four settings reskin batches, 5 met that bar, 4 were reasonable, and 3 asserted only that a widget was absent -- so they would fail once the reskin deleted the type and would have to be deleted by hand. Mechanical renames get no per-item test: a reskin PR tests the defects it finds, not one assertion per file it converts. Verified by: removing the row count and the value text from the engine slider and watching both new tests fail, then pruning the three absent-type asserts and finding the suite unchanged.[2026-09-28, Space Bunny Free] Several review tests are load-sensitive and will flake in a large run, because
pumpAsyncwaits a fixed 80ms of real time for the controller's database futures and then pumps the fake clock by fixed amounts. Under CPU contention less real work fits inside that window, so the controller has not reloaded when the assertions run and they fail on absent data.flutter testgives each file its own isolate, so this is timing and not shared state: the same four directories failed three tests on one run and passed all 128 on the next, unchanged. Before blaming a change for one of these, re-run the file alone, and expect a large parallel run to be the trigger. The real fix is polling on a condition instead of sleeping a fixed interval, across the shared helpers. Verified by:review_controller_test.dartfailing inside a 128-test run and passing 30/30 in isolation, withpumpAsync'sFuture.delayedas the only wall-clock wait in the path.[2026-09-29, Muse Spark] "Run the specific test file" is not a budget: across eight phone-feedback PRs the agent ran each file 3-5 times (new-test-only, full file, stash fail-check, neighbouring files, repeat analyzes), plus a 62-test engine file and a 15-test account batch locally — all work CI repeats on every push anyway. The owner's PC heated up for zero extra safety. Hence §3.1: iterate with --plain-name, one full-file run at the end, fail-check counts as run two, neighbours and ./verify are CI's job. Verified by: the owner reporting the heat and asking for the rule, against a history of green CI runs that had already covered every locally re-run file.
[2026-09-28, Space Bunny Free]
build_runnerwriteslib/l10n/*.dartwith different line wrapping than what is committed, so a fresh worktree shows 52 files and ~38,000 changed lines that are pure formatting. Rundart format lib/l10n/after codegen and the diff disappears entirely. Do not commit the churn, and do not read a non-emptygit statusas real work: check whethergit diff lib/l10n/is a formatting-only rewrite before concluding anything about what a worktree has changed. Verified by: 52 files / 13,844 insertions / 24,106 deletions before the format, empty after, with the test suite green either way.[2026-09-29, Space Bunny Free] Merging a batch of PRs is not delivering it: the owner's own
fvm flutter runin the primary checkout is the acceptance gate for user-visible work, and that checkout stayed atea1d09678while seven PRs merged to388b38f3e. A whole session was spent on "the Account section is missing from Settings" — a feature that arrived in PR #84, after the frozen commit — plus a second app window from a worktree, so two windows with different features produced contradictory observations that were chased in the wrong direction each time. After merging, pullmainin the primary checkout and tell the owner it is current; verify withgit log --oneline -1there againstorigin/main. Verified by: the Account section being unconditional inSrsSettingsScreenand present in388b38f3ebut absent fromea1d09678, and the checkout log showing the stale commit throughout.[2026-09-29, Space Bunny Free] "We couldn't find any user by this name" blames the user's typing and sends you auditing a regex and a repository that are byte-identical to upstream. Superseded 2026-10-02: the host default is now
lichess.org(#124), so--dart-define=LICHESS_HOST=lichess.orgis a no-op, and sign-in is verified working — that symptom had five separate causes, not one. Two parts of the original note still hold: no Lichess client accepts a password, so a "user + password" request is not implementable; and the ruled-out suspects (_emailRegExp,AuthRepository,AuthController,UserRepository.usernameExistsare byte-identical to upstream) stay ruled out. Current state, the five-defect chain, and what is still unverified:docs/open-email-login-handoff.md. Verified by: the owner signing in through the browser on Linux desktop, andGET /api/study/Gg4E2sIS.pgn → 200in the app'shttp_log.[2026-09-29, Space Bunny Free] A worktree is not a working directory, it is the only place you may write, and the isolation rule is easy to break by inattention rather than by a
git reset. Editing two docs in the shared main tree while a worktree sat idle put the edits among another agent's in-flight l10n churn, where they would have been committed as part of unrelated work or reverted with it. Nothing was lost because the diff was saved first, but it had to be extracted, checked line by line to confirm it contained only those two files' changes, reverted, and redone in a worktree. Do all editing inside the worktree and branch fromorigin/main, not localmain: local main was 1 commit ahead of origin with another agent's unpushed work, so a branch cut from it would have carried their commit into the PR. Verified by: diffing the two files to confirm 29 added lines and 0 deletions before reverting, then re-applying the same patch on a fresh branch offea1d09678.[2026-09-30, Space Bunny Free]
e57fcb411is NOT an ancestor ofHEAD, sogit log HEAD..upstream/mainoverstates the upstream gap: fixes reach this fork only as hand-squashed cherry-picks, and at least five are already applied under different hashes (a99dad8a1→bcb66ad5b,25cf0fae5→a0d9ed580,60309eb8d→0fad2d1e3,c60a0edd8→7f2a0c4e7,498ce10a7→02d91a23f). Never attemptgit merge upstream/main— it conflicts on files that are already correct. Realign subsystem-by-subsystem perdocs/upstream-realign.md; current state and Take ranking indocs/upstream-audit-2026-09-30.md. Verified by:git merge-base --is-ancestor e57fcb411 HEADreturning non-zero with 167 upstream-only commits, and each pair confirmed against both diffs.[2026-09-30, Space Bunny Free] "387 surviving upstream files have no per-file GPL header" is a false positive: upstream puts no per-file headers in those same files either, and licensing here is file-level
LICENSE+COPYING.mdexactly as upstream does it. Do not re-raise it in a future audit without first checking the same files atupstream/main. Verified by: header grep scoring 0 for both fork and upstream onstudy_controller.dart,network/http.dart,analysis_screen.dart,board.dart,game.dart, withLICENSEandCOPYING.mdpresent in both trees.[2026-10-13, Space Bunny Free] The real suite's check is named "Tests", not "Unit tests on ubuntu-latest". Two collisions came out of the old name, both found by tripping over them: docs-check.yml reported the same name from a job that runs only an
echo, so branch protection — which matches required checks by name — could be satisfied by a check that ran no tests; and renaming it in #95 silently broke every PR touchingIMPLEMENTATION_PLAN.md, a file gates.yml watches and test.yml did not, so those PRs stopped reporting any required check and blocked permanently. Fixes: #95 renamed the fake to "Docs check", #106 added IMPLEMENTATION_PLAN.md to test.yml's paths, #107 renamed the real job. Verify withgit grep -n 'Unit tests on ubuntu-latest'returning nothing before renaming a check again, and update the required contexts in the repo settings in the same commit. Verified by: #106 being unmergeable withRequired status checkwhile its suite was green, and by diffing both workflows'pathslists, which showed IMPLEMENTATION_PLAN.md was the only file in the gap.[2026-10-13, Space Bunny Free] A clean
flutter analyzecovers the file as it was when you ran it. Adding code afterwards and re-running only the test meant CI caught an escaped quote I had introduced after the analyse, and took a full-suite run red over a string I could not see. Analyse after the LAST edit, not before it. Verified by: CI'sUnnecessary escape of '''on a file that had analysed clean minutes earlier.[2026-10-13, Space Bunny Free] A wrapper that reports a verdict must be checked against the source of truth once, before you trust it. A landing script read the newest CI run without checking the run matched the current head, so it reported "SUITE FAILED" twice for a commit it had never evaluated. The same shape bit twice more: a
Testscheck whose real name turned out to beTests (ubuntu-latest), and a "control" measurement fed 66 ids and read as evidence about 347 rows. Verified by: the script reporting a failure whilegh run listshowed the new run still in progress.[2026-10-13, Space Bunny Free] Optimising against a noisy benchmark is worse than not optimising: the change lands, the number looks halved, and nothing improved. Here the same call measured 16 ms, 29 ms and 36 ms purely by harness and measurement order, so two earlier "this is the bottleneck" claims were both noise. Report spread, not just a median, and check a method's figure across two harnesses before building on it. Verified by: three harnesses disagreeing on
getAllReviewStates()on an unchanged database.[2026-10-01, antigravity/gemini-3.8-flash-tiered] GitHub branch protection requires both 'T1 fast gates' and (as of #107) 'Tests'. When a PR touches only files in docs/**, gates.yml does not trigger, leaving 'T1 fast gates' unreported and blocking merge even with --admin. Verified by: PR #119 failing merge with 'GraphQL: Required status check "T1 fast gates" is expected', while PR #112 merged cleanly because touching AGENTS.md triggered gates.yml.