halkyon/AGENTS.md
Tizian 「ティツィアーン」 1b62c8098c
docs: one repository for every app and shared package (ADR 0015)
The root AGENTS.md holds what applies to every app and package (how
work is done, Serverpod, checks with Melos and the tools, CI); Learn's
own is apps/learn/AGENTS.md, Todo's a note that it is still the
scaffold. The root README introduces the project, Learn's moves to its
folder; LICENSE.md says which licence covers which directory, Todo's
packages unlicensed until decided; one CONTRIBUTING.md. ADR 0015
records the decision; ADR 0014 and 0003 note what moved.

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

17 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 Todo.

  • 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). Learn is the app that runs; Todo is still Serverpod's scaffold; Train and Meditate are to come.
  • 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, with texts of its own (English, German).
    • halkyon_base_server, halkyon_base_client: the Serverpod module every Halkyon server includes (ADR 0014): signing in (anonymous, GitHub, email codes), an account's ways in and its deletion with a hook for each app's own data, the pruning, the instance's basics.
    • 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, 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).
  • deploy/: the instance deploy at the root, learn.halkyon.eu's today (Learn's AGENTS.md, CI and deploys), where other services' compose files are to join Learn's. snap/: Learn's snap, built from the root, which its build needs whole. 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 and Todo'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. 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 server, app and curator and every shared package include it; the generated client packages keep their own; Todo's packages still have the scaffold's (lints, flutter_lints) until Todo is built on the shared packages. 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 server.dart configures the module; Todo's scaffold still has Serverpod's own email sign-in).

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. 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 and Todo'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), 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 (Learn's icons with Pillow, 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. tool/release.sh and tool/fdroid_repo.sh are Learn's release, in Bash until build 21 is out; then a release tool in Dart, shared by the apps, takes over (ADR 0015).

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. 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 also runs dart run melos run generate with the Serverpod CLI at SERVERPOD_VERSION and fails if the committed generated code of any server differs from its output. 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).