halkyon/AGENTS.md
Tizian 「ティツィアーン」 438ce20dbf
build: Android's built-in Kotlin, the three plugins applying KGP themselves patched in third_party/
AGP 9 compiles Kotlin itself (android.builtInKotlin=true in every app),
and warns that applying the Kotlin Gradle Plugin will soon fail.
flutter_web_auth_2 5.1.0, keyboard_height_plugin 0.3.0 and flutter_tts
4.2.5 still apply it unconditionally, so their pub releases are copied
into third_party/ with only their android/build.gradle changed, and
the root overrides them by path (third_party/README.md says when to
drop each). The servers' images copy third_party/, as pub refuses an
override whose path is missing. third_party/ isn't our own code: left
out of the analysis and the format check.

All four apps' APKs build without the warning. A Kotlin daemon's
session file that slipped into Train's android/ goes, and every app's
android/.gitignore keeps .kotlin/ out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-11-04 09:32:13 +01:00

26 KiB

The Halkyon project

Halkyon's apps and the packages they share, in one repository and one pub workspace (ADR 0015, decided 2026-10-19). This file holds what applies to every app and package; each app's own AGENTS.md holds what is its alone, and is read before working in it: Halkyon Learn, Halkyon Meditate, Halkyon Todo, Halkyon Train.

  • Working notes: if NEXT.md exists at the repository's root, read it first. It holds the current step, the plan and the reasoning behind each decision. It is local and never committed.

Layout

  • apps/<app>/: each app's packages, as serverpod create lays them out (halkyon_<app>_server, _client, _flutter), plus what is the app's alone (Learn's curator, the scripts writing Learn's assets in apps/learn/tool/, run from apps/learn; the apps' icons are the root's tool/app_icons.py, ADR 0031). Learn is the app that runs; Train, Todo and Meditate run too (ADR 0030, ADR 0033, ADR 0038), Todo and Meditate not released yet.
  • packages/: what the apps share, each with its README and licence, which the apps take by path (path: ../../../packages/<package>), so a change to a shared package and to the apps using it is one commit:
    • halkyon_ui: Halkyon's look, the theme around cobalt and copper, Noto Sans bundled, the window-size classes and the widgets every screen is built of (the floating bar and rail, FoldingTabs' tab row folding into the title), with texts of its own (English, German).
    • halkyon_base_server, halkyon_base_client: the Serverpod module every Halkyon server includes (ADR 0014): signing in (a password with TOTP and the resets of ADR 0017, SimpleLogin on halkyon_oidc, email codes proving an address; no anonymous accounts since ADR 0020, D25, nor GitHub since D36), an account's ways in and its deletion with a hook for each app's own data, the pruning, the instance's basics, people's handles and public profiles, who may sign up (SIGN_UP: open, approval, closed) with the accounts waiting for approval and the instance's operators who approve them (Halkyon Learn's ADR 0021, step 1), the operator's assistants forwarded on halkyon_llm with their grants, each person's budget, the efforts they allow and counted figures, and the instance's privacy page made from its settings (step 2), and the instance's register (ADR 0016): one account per person across an operator's services, held by one service (Learn's server on learn.halkyon.eu) and asked by the others, its members (ACCOUNT_REGISTER_URL), all sharing a secret (INSTANCE_SECRET); its README says how.
    • halkyon_app: every app's shell on the module's client (ADR 0014): the instances a device keeps accounts on and the first start, signing in and switching person, the gates building an app's dependencies per instance and per person, the sync service and where it stands, the account section of an app's settings (AccountTiles), every app's Profile (HalkyonProfile, decided 2026-11-02: the person's own tab, the same in every app, with their account, the ways they sign in and their public profile, one for all apps, then the app's own tabs) and people's public profiles (PublicProfileRepository, PublicProfileSection, showProfileOf), the wait screen of an account waiting for approval and the operator's card, badge and screen (ADR 0021), the assistants of an app's settings (AssistantsCard: granted, by provider, agreed to, what is left of the person's budget, used, and the person's own keys) and the operator's grants from that screen (AssistantGrantsScreen: several assistants to several people at once, each with one budget, D57 to D63), with the repositories asking them through the module, at an effort the operator allows, or a person's own key directly (step 2) and what an app's warning before a private studyset goes names (providerTermsOf, OwnAssistant.onThisMachine, step 3), About with the instance's source link (showAppAbout), the device's own speech recognition (SpeechInput, DeviceSpeechInput on speech_to_text, FakeSpeechInput in testing.dart; none on Linux) and dictation into a text at its cursor, listening again after each pause (Dictation, Meditate's ADR 0038, D15; an app that listens declares the microphone and its usage texts itself), what's new after an update (WhatsNew, WhatsNewOnUpdate, WhatsNewTile, from the app's assets/whats_new/, Train's ADR 0030, D61), the language every app picks (resolveAppLocale) and countries' and subdivisions' names (Unicode CLDR's, bundled), with texts that name the app.
    • halkyon_core (low-level primitives in pure Dart: ids, a logger, SerialQueue, CoalescingRunner) and halkyon_interop (contracts between apps, pure Dart).
    • halkyon_keys (pure Dart on libsodium, ADR 0017): an account's keys on the device, the password's Argon2id and SRP's x, the key pairs and their wrapping, the recovery phrase, data keys, content sealed and signed, row IDs keyed by the account (keyedRowId). SRP-6a's two halves are the module's (halkyon_base_server's base.dart, halkyon_base_client's srp.dart), as are TOTP and the recovery codes. sodium's build hook compiles libsodium, so every machine that builds or tests needs a C compiler and make.
    • halkyon_llm (pure Dart, Halkyon Learn's ADR 0021, D15 and D46): language models' APIs streamed over plain HTTP, no SDKs: Anthropic's Messages API, OpenAI's chat completions (each known host's fields, D45) and its Responses API, behind one interface (LlmClient), with FakeLlmClient for tests; tested on replies written from the providers' documentation and on those tool/record_llm.dart records with a key (D36). The module forwards the operator's assistants with it, and the apps call a person's own provider. BSD-3-Clause as halkyon_oidc, publish_to: none.
    • halkyon_oidc_server, halkyon_oidc_client (ADR 0023): signing in with OpenID Connect, with a SimpleLogin preset, as a Serverpod module that halkyon_base builds on; halkyon_oidc_flutter, the device's half: the provider's page opened, the way back caught (the app's scheme, or the loopback on Linux and Windows, LoopbackWebAuth) and its state checked. Nothing of Halkyon's is in it, so that others can use it with Serverpod: under BSD-3-Clause, Serverpod's own licence, where the other packages are AGPL; publish_to: none until it is published to pub.dev, which is asked again first.
  • third_party/: others' packages patched for us, each a pub release copied whole with only what its README names changed, overridden by path in the root pubspec.yaml (today the three Android plugins still applying the Kotlin Gradle Plugin themselves, which AGP 9's built-in Kotlin refuses soon); not our code, so left out of the analysis and the format check; the servers' images copy it, as pub refuses an override whose path is missing. Drop an override once its package's release has the change.
  • deploy/: the instance deploy at the root, learn.halkyon.eu's today (Learn's AGENTS.md, CI and deploys), where other services' compose files join Learn's (Train's compose.train.yaml, Todo's compose.todo.yaml, each switched on in .env). snap/: Learn's snap, built from the root, which its build needs whole; Todo's and Train's are apps/todo/snap/ and apps/train/snap/, which the release tool builds each in a clone of the repository with it in place of snap/ (Todo's AGENTS.md, Releases). docs/adr/: the decision records of every app and package, one numbering. tool/: the repository's tools (below).
  • One workspace, one lock: the root pubspec.yaml lists every package, pubspec.lock holds one version of each dependency for all of them, and its dependency_overrides hold for every app (the offline sync fork, Learn's AGENTS.md; cli_util, below). Serverpod's version moves as one: every package pins Serverpod's packages to the same version (4.0.4), the module's, Meditate's, Todo's and Train's too, together with the CLI version CI generates code with (SERVERPOD_VERSION in .forgejo/workflows/ci.yaml) and the sync packages Learn's server depends on.

How work is done here

  • The agent implements; the developer decides. Put every decision to the developer before building (design choices, alternatives, framework defaults kept or changed). The first time a new pattern appears (e.g. the first repository/gateway pair, the first synced model, the first endpoint with its denial tests, the first Serverpod module), walk through it in detail with the developer; afterwards, implement exceedingly similar cases directly and point out only deviations.
  • Tests come first and state the specification. Every access rule gets a test that asserts denial, not only permission.
  • Explain every migration's SQL.
  • Before keeping a framework default, name the alternatives and recommend one, especially where a choice becomes expensive once users or data exist.
  • Every decision carries its reason (in NEXT.md or an ADR under docs/adr/).
  • Commits are GPG-signed and carry a DCO Signed-off-by trailer (git commit -s; a local prepare-commit-msg hook adds it automatically), and keep their type prefix (feat, fix, perf, refactor, docs, build, chore, a ! or a BREAKING CHANGE: line for what breaks): releases work out their versions from them (Learn's AGENTS.md, Releases).
  • Servers are named in prose by the domain they serve (learn.halkyon.eu's server, the halkyon.app server), which says what a reader needs; the SSH aliases and the CI runner's label keep the servers' own names (sahaquiel-deploy, ramiel-fdroid, sahaquiel:host).
  • Pushes pass the checks first, through a local pre-push hook (in a fresh clone: ln -s ../../tool/pre-push .git/hooks/pre-push): tool/pre-push first refuses a push to main that brings a commit not signed by a key in deploy/allowed-signers (tool/check_signatures.sh, the deploy's own rule), so it fails on the laptop rather than after CI, then runs tool/check.dart, but for a push of release commits alone (the release tool's chore(release): commits changing an app's version line and its texts of what's new, nothing else: tool/release_push.dart, decided 3 Nov 2026, so that releases in a row don't run the checks once each; CI still runs them). CI runs the checks again, and a green main deploys learn.halkyon.eu (see CI and deploys), so an unsigned commit on main blocks every deploy after it.
  • Lints: LeanCode's strict set (leancode_lint), configured once in the root analysis_options.yaml, which also enables its analyzer plugin. Plugins can only be enabled at the root of a pub workspace and only reach packages whose options include that file: Learn's, Todo's and Train's servers and apps, Learn's curator and every shared package include it; the generated client packages keep their own, but for the module's, which includes it for its SRP half and leaves lib/src/protocol/ out. Every // ignore: gets a comment saying why (document_ignores). Each server package switches off the four rules for Dart v3.13's constructor syntax (use_primary_constructors, use_declaring_parameters, empty_container_bodies, unnecessary_type_name_in_constructor): Serverpod's code generator (checked with v4.0.3) can't parse that syntax in endpoint files yet.

Flutter and Serverpod

Each app is a Flutter app (frontend) backed by a Serverpod server (backend). Always build an app's backend with Serverpod. Build for multiple users, with Serverpod's built-in authentication, which a server gets from halkyon_base (Learn's and Todo's server.dart configure the module; people sign in with a password and TOTP, ADR 0017, through halkyon_app's screens).

The user starts an app's server and Flutter app with serverpod start in that app's server package (Learn's: apps/learn/halkyon_learn_server). There is no need to check if the server is running: make the changes and call the serverpod MCP tools as needed. If the server is not running, an informative error message will be received from the MCP server. Then STOP and ask the user to start it. NEVER start the server yourself. The Flutter app is started along with it, or can be launched from the MCP tool spawn_flutter_app.

While running, serverpod start watches for file changes to run incremental code generation and hot reload both the server and the Flutter app. Calling serverpod generate directly is not needed, but might be useful to troubleshoot when an incremental generation fails.

ALWAYS use the MCP server instead of the command line. Use the MCP server to:

  • create_migration and apply_migrations for database (after you change data models).
  • create_repair_migration if the database has drifted out of sync with the migrations.
  • tail_server_logs to read logs from the server.
  • tail_flutter_logs to read the raw stdout/stderr of the Flutter app.
  • hot_reload / hot_restart to reload or restart the server and the Flutter app. ALWAYS call hot_restart after doing changes in the Flutter app that may not work with normal hot reload (which is automatically applied).
  • spawn_flutter_app to start a Flutter app declared under serverpod: flutter_apps: in the server pubspec.yaml.
  • get_flutter_app_dtd (Dart tooling daemon) for connecting to the app through the dart MCP.

The serverpod MCP tools come from .mcp.json: serverpod mcp-server, a bridge to the running serverpod start of one app's server, the one its --server-dir names (each app's AGENTS.md gives its own). They load when a session starts; if they are missing, the server wasn't running or the session predates .mcp.json. Like all agent configuration, .mcp.json is git-ignored. A session works with one app's server at a time; work on another app's server package with the CLI below, in a worktree if that app's serverpod start runs.

NEVER edit generated code. Each server's lib/src/generated/ directory and its whole client package (apps/<app>/halkyon_<app>_client, packages/halkyon_base_client) are rewritten by the code generator, but for the module client's SRP half, written by hand (lib/srp.dart, lib/src/srp/, test/): the generator writes only a client's lib/src/protocol/ (checked with v4.0.4). Change the .spy.yaml models, the endpoints, or the server's lib/server.dart instead.

Migrations are a narrow exception: the migration.sql of a generated migration MAY be edited by hand when the generated SQL would lose data — to add a data transformation, or to reach a destructive change through non-destructive steps. Never touch the other files in the migration directory, and keep the schema the SQL ends up with identical to definition.sql — new databases are created from that file and never run migration.sql. A module's tables are part of each server's migrations that includes it; moving a table into a module unchanged needs no SQL but the version rows (create-migration --empty, ADR 0014).

Pass --no-analytics to every serverpod call (before the command: serverpod --no-analytics generate). Otherwise the CLI reports the commands run and the Serverpod features the project uses to PostHog; there is no setting or environment variable to switch that off. The VS Code task, .mcp.json and Melos' generate script pass it already.

Only when the server cannot be started at all, or the MCP tools serve another app's server or checkout, fall back to the CLI in the server package:

  • serverpod generate to regenerate the client and the generated server code (dart run melos run generate for every server package at once).
  • serverpod create-migration after changing a model with a table (add --force for destructive changes). It only writes the migration; serverpod start applies pending migrations when it boots the server.

Tests need no Docker. Each server's config/test.yaml sets database.dataPath, so Serverpod starts and manages the test database (an embedded PostgreSQL) itself. Just run dart test in the server package, which needs the test passwords in its git-ignored config/passwords.yaml (throwaway values for the module's, Todo's and Train's tests; Learn's real file for Learn's) or from the environment as CI gives them.

Tooling traps: rules of leancode_lint's analyzer plugin are ignored with its prefix (// ignore: leancode_lint/missing_equatable_props); dart fix --apply moves flutter_driver into an app's dependencies (as any) on every run, so restore it as a dev dependency (sdk: flutter) afterwards; never dart format generated code, only the hand-written paths, as tool/check.dart does (a server's test/integration/test_tools/ is generated too, so format test files one by one, never the whole test/ folder); a UUID table's id column takes only a set typed Set<UuidValue> in inSet and notInSet (_uuids in Learn's sync_tag_gateway.dart): it is nullable as declared, so a set literal in its place becomes a set of nullable ids, which Serverpod (v4.0.4) refuses at run time with a type error fake gateways never meet (found 2026-10-17: taking a tag off anything had failed on devices since v0.1.0); and never rebase or check out other commits while serverpod start runs: its watcher regenerates code for each intermediate state, leaves stale generated files behind and hot-reloads them. Stop it first, or work in a separate worktree.

If the user asks you to test an app:

  1. Use get_flutter_app_dtd (serverpod MCP) to get the Flutter app's DTD
  2. Pass the DTD to connect_dart_tooling_daemon (dart MCP) to connect to the app
  3. Use flutter_driver (dart MCP) to navigate through the app (each app's AGENTS.md says where it starts the driver extension)

Checks, Melos and tools

Checklist after doing changes, in this order:

  • dart format (CLI) on the files you changed

  • create_migration and apply_migrations (MCP - only if necessary)

  • dart run tool/check.dart (CLI): the packages the lock names (flutter pub get --enforce-lockfile, so a merge never leaves the resolution behind), the generated code (every server's, dart run melos run generate, about 15 s: it fails, naming the files, where they differ from what the generator makes of the models and endpoints now, leaving them written anew to be committed, and refuses a Serverpod CLI at another version than the lock holds Serverpod at, dart install serverpod_cli@<version>), format check of our own Dart files, dart analyze --fatal-infos over the whole workspace, the tools' own tests, and every package's tests through Melos. With --changed-since=origin/main it tests only the packages changed since then and those depending on them. The pre-push hook runs it in full, so a push only goes through when it passes.

  • Do serverpod MCP hot_restart if required (hot reload is done automatically). Will also hot restart Flutter app

  • Check serverpod MCP tail_server_logs and tail_flutter_logs for any issues.

  • Melos (decided 2026-10-19, ADR 0015) runs scripts across the workspace's packages: a dev dependency of the root (dart run melos …, its version in the lock), its scripts under melos: in the root pubspec.yaml: analyze; test:dart (the packages without Flutter, one at a time, as each server's tests start a PostgreSQL of their own on the same port); test:flutter (flutter test -x requires-server, one package at a time); generate (Serverpod's code generation in every package with a config/generator.yaml, the CLI on the PATH); check (tool/check.dart). Every script takes Melos' filters: dart run melos run test:flutter --scope=halkyon_app, or --diff=origin/main --include-dependents for what changed and what depends on it; dart run melos list lists the packages. The root overrides cli_util to 0.5.2 for it: Learn's flutter_launcher_icons (0.14.4, its latest) asks for ^0.4.1 and uses only the logger, which 0.5 keeps; drop the override once that package allows 0.5.

  • Tools are Dart where they hold logic (decided 2026-10-19): conditions, parsing, parallel work, anything worth a test, as tool/check.dart (its file selection in tool/src/check_files.dart, tested in the root's test/). Bash for glue, short and linear (tool/pre-push, tool/check_signatures.sh, tool/workspace_only.sh, which narrows the root pubspec's workspace to the packages an image builds), and for everything run on the production host, which has no Dart SDK and gets none (deploy/redeploy.sh, deploy/backup.sh). Python only where a library exists only there (the apps' icons with librsvg and Pillow, tool/app_icons.py, its Anki fixtures with Anki itself); Learn's data scripts and packages/halkyon_app/tool/subdivision_names.py need nothing but Python's standard library and may move to Dart when they next change. Releases come from tool/release.dart (decided 2026-10-19, ADR 0015), shared by the apps, each configured in Dart beside it (apps/<id>/release.dart, typed and commented, registered in tool/src/release/apps.dart): dart run tool/release.dart learn [major | minor | patch] [--dry-run], or several apps one after another (train:minor learn, decided 3 Nov 2026: each as alone, the run stopping at the first that fails; tool/src/release/targets.dart), on a developer's laptop, never in CI, through tool/with_signing_keys.sh learn … (Bash glue, fetching every named app's key): the keystores are kept in Proton Pass alone (vault Infra · Signing, each a note's attachment, the password its hidden field), and it downloads the app's and the F-Droid index's into a folder in memory ($XDG_RUNTIME_DIR), hands their paths and, but for a dry run, their passwords (each item's hidden field Password, in the environment as <keystore variable>_PASSWORD, so the tool asks for neither) and the Forgejo token (a login in the vault Infra, HALKYON_FORGEJO_TOKEN) to the release tool and removes the files however the run ends (test/release/signing_keys_test.dart holds it to every app released). Tags are per app (learn-v0.19.0; an app's earlier tags, Learn's v…, count as its history), and only commits touching the app decide its version: its folder, the shared packages its packages use by path (transitively), the root pubspec and lock, its release files (key pins, F-Droid listing, snap recipe) and where its packages were before a move. Each release's What's new (Train's ADR 0030, D61), one file a language in the app's assets/whats_new/ (<version>.<language>.txt, English and German), is drafted by it from the commits after Go on? and edited in $VISUAL/$EDITOR until it fits (no TRANSLATE: left, F-Droid's 500 characters), committed with the version, and is the app's sheet after the update, the Forgejo notes and F-Droid's changelog. --dry-run reads everything a release would (the version, the commits that count and what each touches, the steps, the notes it would publish), changes nothing, asks for no secret, and lists what a release would refuse. Its pure logic is in tool/src/release/, tested in the root's test/release/. tool/fdroid_repo.sh stays Bash (glue for fdroidserver); tool/with_signing_keys.sh fdroid [APK] runs it alone with the index's key, e.g. when a release's upload failed after its release was made; a release colliding with the entry Forgejo makes of the pushed tag itself asks again, a few seconds later. Build 21 (v0.18.0) was the last release of tool/release.sh, the Bash script it replaced, removed since.

CI and deploys

  • CI (.forgejo/workflows/ci.yaml) runs dart run tool/check.dart on every push of a branch (not of a tag: a release pushes its tag right after main, on the same commit), on the Forgejo runner on learn.halkyon.eu's server (deploy/runner/), except for a commit that passed before: the job marks passed commits in the runner's home (checked/, kept 90 days), so a branch's commit fast-forwarded into main goes straight to its deploy. It checks every package, whichever a push changed. Jobs run directly on the host as the unprivileged user runner, never in containers: Docker access would be root on the production server. The runner's systemd unit keeps jobs away from production (no /home, no Docker socket, Docker's container networks unreachable, memory and CPU capped); keep it that way when changing it. runner has user ID 61000 and must never share one with a container's processes (PostgreSQL and Learn's server run as 999, not remapped): the same user ID can read their environment and files. The job's first step (Sandbox) fails if any of this leaks. Its second (Leftovers) stops whatever an earlier job left running in a job's working folder: the runner (forgejo-runner v13.1.0) kills a cancelled or timed-out step's process group and runs no later step, not even an if: always() one, and on 22 Oct 2026 a cancelled job still left its 4 GB analysis server behind; with one job at a time, any such process is a leftover. Flutter is pinned in the workflow (FLUTTER_VERSION): move it together with the local one. Every server package's tests get throwaway passwords from the environment (SERVERPOD_…, new for every job), since no passwords.yaml is ever committed; without them they exit with code 1 and no message (Serverpod's own template does the same). CI installs the Serverpod CLI at SERVERPOD_VERSION (which the root's tests hold to the lock's Serverpod), so the checks' generated-code step fails there too if the committed generated code of any server differs from what the generator makes, as it does on the laptop since 23 Oct 2026 (before, only CI compared, and a push went red for an endpoint's changed comment). So never run dart format over a server's lib/ or test/ as a whole: it reformats the generated code (lib/src/generated/, serverpod_test_tools.dart); format the files you wrote. CI runs on pushes only: a pull request from a fork would run its code on the production server.
  • A green main deploys learn.halkyon.eu: this repository's main, whichever app or package a push changed, through deploy/redeploy.sh (Learn's AGENTS.md, CI and deploys, with the rules a deploy checks, the sidecars, the backups and the releases).