Skip to main content
Home / Documentation / Developer Guide & Toolchain
Development

Developer Guide & Toolchain

Prerequisites, FVM setup, worktree-per-task workflow, code generation, and running ChessSRS locally.

Source in app repo: docs/setting_dev_env.md
Source in app repo: CLAUDE.md
Source in app repo: AGENTS.md
App version: v1.4.0
Licence: GPL-3.0

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.

  1. 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.
Warning

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.

  1. 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:

  1. Connect your phone to the same wifi network as your host machine
  2. In Developer Options, toggle Wireless Debugging to ON
  3. Tap "Wireless Debugging" to enter its menu
  4. Select Pair device with pairing code

On your computer:

  1. Pair your phone with adb pair [phone_ip_address]:[pair_port] [pairing_code].
  2. 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.
  3. 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

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
Warning

Do not use any scheme (https:// or ws://) in url in host, since it's already handled by URI helper method

Note

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
Note

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:

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:

  1. autoDispose + ref.read interaction: Many providers are FutureProvider.autoDispose and use ref.withClientCacheFor which calls keepAlive() to prevent premature disposal. Overriding the provider directly bypasses keepAlive(), so the provider can be disposed before its future resolves — causing silent test failures where navigation never happens.

  2. 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 => fakeValue skips 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:

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:

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

  1. Edit translation/source/mobile.xml for mobile-specific strings
  2. 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:

Binding Layer: LichessBinding (in binding.dart) provides a testable abstraction for plugins and external APIs:

Use AppLichessBinding.ensureInitialized() in production, TestLichessBinding in tests.

Network Layer:

Services: Long-running background services initialized in app.dart:

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:

  1. Microtask Queue (higher priority)

    • Executed before the event queue
    • Scheduled with scheduleMicrotask() or via Future completions
    • Used internally by Future.then(), async/await
  2. Event Queue (lower priority)

    • I/O events, timers, user interactions
    • Scheduled with Future(), Timer, Stream events
    • 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:

  1. Current function execution pauses
  2. Control returns to event loop
  3. 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):

Stockfish Engine Communication:

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:

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):

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

Analysis

Code Generation

This project heavily uses code generation. Always run dart run build_runner build (or watch) after:

Generated files are NOT committed to git.

Common Gotchas

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

Flutter Integration

The Flutter app (via home_widget package) writes to the shared App Group from app.dart:

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:

  1. ARCHITECTURE.md (layer boundaries, licensing constraints)
  2. CUT_PROPOSALS.md (what Lichess functionality is removed/kept, and status)
  3. docs/INTEGRATION_MAP.md (how Listudy/chessrs concepts are integrated)
  4. CLAUDE.md in 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:

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:

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

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:

  1. The suite is green — from CI, or ./verify when working offline.
  2. Launch the real application (Linux desktop or attached device) and manually exercise the affected feature.
  3. Visually inspect the UI. For UI work, screenshot/runtime inspection is required — "tests pass" is not evidence of "visual pass".
  4. 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.

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

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

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):

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)

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/.

  1. 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.md and GATES.md. A bugfix starts with a test that fails on the current code.
  2. 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.
  3. 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.
  4. 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.
  5. Run ./scripts/gates.sh t1 (needs BASE_REF, defaults to origin/main) before pushing — after committing, because the diff-based gates read origin/main...HEAD and 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.
  6. New tests cite their invariant (// SPEC INV-xxx.); G05 rejects orphaned invariants and unknown IDs.
  7. 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.
  8. 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:

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).