Aller au contenu

Development

Status: describes the current tooling (package.json scripts, scripts/dev, scripts/quality and .github/workflows/ci.yml). Commands run from the repository root.

Setup

Install Node 24.22.1 (.node-version) and pnpm 11.20.0, then:

pnpm dev:setup    # frozen install, Desktop Rust toolchain, Git hooks
pnpm dev:doctor   # checks the toolchain, hooks, commit signing, CLI shim and dev isolation
pnpm dev:cli      # optional: installs ~/.local/bin/maia for this checkout

The install pins TypeScript 7.0.2 as every package's tsc, and the TypeScript 6 API as typescript (CLI tsc6) for ESLint, Expo and repository scripts (D-063). Nothing in development, tests or CI needs Docker or starts a container.

Daily loop

CommandWhat it does
pnpm maia <args>Runs this checkout's CLI and rebuilds it when its source is newer.
pnpm check:changedFast gate: only the checks your changes since origin/main can affect, tests included. The pre-push hook runs it.
pnpm check:qualityFormat, policy, lint, typecheck, build, through the Turborepo cache.
pnpm check:growthThe change's net source lines against the growth budget.
pnpm dev:cleanLists finished dev worktrees and build outputs to remove; --yes removes them (clean up).
pnpm checkFull gate: check:quality plus pnpm test.
pnpm buildBuilds every package through the Turborepo cache.
pnpm metrics:compareSize, health and startup of your change against its base, on this machine (measure performance).

pnpm check:changed maps each changed file to its package, adds the packages that depend on it, and runs lint, typecheck, build and test for those only. Documentation changes run format and policy checks. A change outside the packages (root configuration, scripts/, workflows, the lockfile) runs everything. The growth budget runs last. --base <ref> picks another base, --since <ref> checks only what changed after a later commit (the growth budget still measures the whole change), and --dry-run prints the plan. Package tests get a throwaway MAIA_HOME.

The gate and the root scripts keep memory low on a shared host:

  • Turborepo runs two tasks at once (turbo.json), and Vitest two workers. MAIA_GATE_CONCURRENCY and VITEST_MAX_WORKERS raise them for one run.
  • Where flock exists (Linux), one gate runs at a time on a host: a second waits on a lock file in the temporary directory.
  • ESLint and tsc keep caches in node_modules/.cache, so a rerun checks only what changed. A type-aware lint rule can miss a type change in another file; CI starts every run with no ESLint cache and catches it.
  • No script starts a dev server or a watcher on its own. Stop the ones you start, and run pnpm dev:clean once your worktrees' work lands.

Git hooks

pnpm install (its prepare script), pnpm dev:setup and pnpm dev:worktree create set this clone's core.hooksPath to .githooks/, so every linked worktree of the clone uses its own hooks. They change the clone's local Git configuration, never the global one. Some tools that create worktrees (Claude Code's agent worktrees among them) rewrite a relative hooks path as an absolute one into the main checkout, which pins every worktree to that checkout's copy. Three things undo it: each .githooks shim hands over to the running worktree's own hook when it is not that hook; pnpm install replaces an absolute path to any .githooks below the clone's root or in one of its worktrees and keeps any other path you set yourself (pnpm dev:setup replaces it); and pnpm dev:doctor reports hooks that are off, pinned or foreign, with the fix. A pinned directory that predates the hand-over (for example a bare clone's leftover .githooks) still runs its old shims; copy the current ones into it.

HookWhat it checksMeasured time
pre-commitStaged files only: a guard against token-shaped secrets in added lines and files over 1 MiB, the lint policy (English only included), Prettier, and ESLint on the files CI lints.0.2 s docs, 1.6 s a script, 2–3.5 s a package source file, 0.8 s once ESLint's cache has it
commit-msgA Conventional Commits subject, type(scope)!: summary, in English. Git's merge, revert and fixup! subjects pass.under 0.1 s
pre-pushpnpm check:changed: what CI checks for the change, tests included, and the growth budget. A push that fast-forwards a branch the remote has checks only what changed since the remote's commit. Only a push of branch deletions skips it: tags, every remote and commits a tracking ref holds get the checks.4 s documentation; 2.5 min a CLI change (it runs the Node tests too); 3 min a root or scripts/ change, on a 12-core laptop
  • Signatures: every commit must be signed; the repository refuses unsigned pushes (D-135). Before the gate, pre-push stops a push whose new commits have no signature header and prints the fix (GitHub verifies the signature itself), and pnpm dev:doctor warns when this clone does not sign. It reads an update's range as remote..local and a new branch's as what the destination remote's tracking branches lack. A ref whose range this clone cannot establish (the remote's commit is missing, or nothing tracks the remote) or that adds over 10,000 commits or 64 MiB of them gets a "not checked" note, and GitHub enforces it. --no-verify does not help: GitHub rejects the push.
  • Prettier checks what is staged and never rewrites files: rewriting and re-staging would also stage the hunks you left out with git add -p. A failure prints the command to run: prettier --write then git add, or git add -p for a partially staged file.
  • Merges: while a merge concludes, the hook checks the files that differ from both parents, not everything the merged branch brought in.
  • ESLint lints fully staged files through a content cache in .cache/eslint/ (an unchanged file is not linted again) and partially staged files from the index. Typed rules need the workspace packages a file imports to be built, so before pnpm build the hook skips those files with a note; pre-push and CI lint them.
  • pre-push checks the working tree, not only the pushed commits, so uncommitted files take part, as in pnpm check:changed; the growth budget counts commits only. A follow-up push to a branch the remote already has passes --since <remote commit>, trusting that commit passed the gate on its own push, so a documentation commit on a large branch takes seconds. CI catches what a commit pushed with --no-verify or from another clone breaks. A new branch, a force push, a tag, or a push of two or more refs checks the whole change.
  • Nothing to check: a push of branch deletions alone runs no check, since a delete carries no objects. Every other push gets them: a tag can carry commits, and neither a remote's URL (an SSH alias, a mirror, a fork) nor a remote-tracking ref such as origin/main shows that CI judged the commits. The stamp below is the fast path.
  • Gate stamp: a pnpm check:changed that passes on a clean working tree records the commit's tree, the base it compared against, the Node version with the real path of its executable, and the variables turbo.json hashes in the worktree's Git directory. Each gate replaces the worktree's stamp, so a push of an older gated tip runs the gate again. A push skips the gate when each pushed tip matches the stamp on those four, the stamp is under 24 hours old and the working tree is clean. The base must be the one the push would compare against, so a stamp from a wider or narrower gate does not count. The signature and growth checks still run, so an empty commit or a push right after the gate takes seconds. Like the gate, the stamp covers each tip, not the commits below it. A push with a ref or commit range the hook cannot read in full runs the gate. A variable that changes which tests run or what they do goes in globalEnv or the env of the turbo.json task that runs them. A test checks that list against the MAIA_ switches turbo.json passes through, NODE_OPTIONS and PATH; it cannot see a variable it does not name. The stamp hashes PATH without repeats and without the directories this checkout's Git and pnpm put in front of it: Git's default exec directory, the node_modules/.bin of the root and of each package, and the node-gyp-bin of the pnpm that npm_execpath names, which pnpm run adds. Any other directory counts, another worktree's node_modules/.bin and a GIT_EXEC_PATH override included, since each can hold another runner. The stamp is a trusted local cache: anyone who can write the Git directory can forge one, and CI runs the gate again.
  • Output is one line on success, with the time of any step over a second, and each problem with its fix on failure.

To skip the hooks once, use git commit --no-verify or git push --no-verify; MAIA_SKIP_HOOKS=1 skips every hook for one command, for example MAIA_SKIP_HOOKS=1 git rebase main. CI runs every pre-commit and pre-push check again; only the hook checks commit messages.

Growth budget

Each change pays for new code with deleted code. pnpm check:growth counts the source lines the change's commits add and delete since their merge base with origin/main; uncommitted work, tests, fixtures, documentation, declaration and generated files are free. Net growth above +200 lines warns; above +400 it fails unless a commit in the change has a Growth-Accepted: <reason> trailer. The pre-push gate runs it last, and CI runs it on each pull request against its base and on each push to main against the previous commit (a force push whose previous commit CI cannot see skips it). CI checks it in a job of its own, so an overrun never stops the tests from reporting. The trailer counts in any paragraph of a commit message that holds nothing but Key: value lines, so the squash message GitHub writes from the branch's commits keeps it (D-130, D-131).

From change to main

 edit ─▶ pre-commit ─▶ pre-push ─────────────▶ CI ──────────────────▶ main ─▶ pnpm dev:clean
         staged files   pnpm check:changed      the same checks,        the     removes the
         (seconds)      scoped tests, growth    then Linux and macOS    change  merged worktree
                        (seconds to minutes)    hosts, E2E, metrics     lands

The pre-push gate runs what CI would fail on for the change, on this machine, so a red CI run means a platform difference (Linux, the runners) or a check only CI runs (process hosts, E2E, metrics), not a missed step. CI runs only the jobs the change can affect (CI). Pull requests merge by squash, the only method the repository allows (D-131).

Parallel worktrees

One command gives a person or an agent an isolated checkout:

pnpm dev:worktree create fix-login            # from HEAD; --base, --branch, --path
cd ../maiaos-worktrees/fix-login
pnpm dev:node                                 # build and run this worktree's Node
pnpm maia status                              # in another terminal: reaches that Node
pnpm dev:worktree list                        # every dev worktree and its Node state
pnpm dev:worktree remove fix-login            # from outside the worktree

Each dev worktree has:

  • its own branch (wt/<name> unless --branch names one);
  • dependencies cloned from the shared pnpm store, or copied where the filesystem cannot clone (an offline install, online only when the store lacks a package);
  • a Turborepo cache shared with the other dev worktrees through .cache/turbo, a link to ~/Library/Caches/maia-os/turbo on macOS or $XDG_CACHE_HOME/maia-os/turbo (MAIA_DEV_TURBO_CACHE overrides it);
  • its own Maia profile dev-<name>-<random> and its own MAIA_HOME at .cache/dev/maia-home. The record sits in the worktree's Git metadata (.git/worktrees/<id>/maia-dev-isolation.json), so git clean -fdx keeps it. pnpm maia and pnpm dev:node apply both, so the worktree never reads or writes your real ~/.maia or reaches your default-profile Node. Run pnpm maia login there if the worktree needs accounts. --base must include this tooling.

The profile gives the Node its own runtime directory and database. The worktree's MAIA_HOME is a disposable development home, like the temporary one tests use, not a second user home: yours stays ~/.maia.

Ports

Every checkout owns a block of 100 loopback ports in 20000–29999, below the ports the OS gives outgoing connections (Linux from 32768, macOS from 49152) and Kubernetes' NodePort range (from 30000), so parallel worktrees never share a port. The dev Node still probes each port before it leases one, so another program's listener only shrinks the Session range. create records the first free block for the worktree; any other checkout, the main one included, derives its block from its path. pnpm dev:worktree list and pnpm dev:doctor show the block.

Port in the blockUsed byOverride
firstDesktop dev server (pnpm dev:desktop)--port
first + 1client E2E serverMAIA_CLIENT_E2E_PORT
first + 3offline gateway of pnpm dev:lab scenario stressnone
first + 4loopback sshd of pnpm dev:lab up --sshnone
first + 10 – lastports the worktree's dev Node leases to Sessions (MAIA_PORT_RANGE)none; the block always wins

The Node's HTTP API listens on an ephemeral port it publishes in its runtime directory. Expo picks the next free port itself.

remove refuses while the worktree's Node or a lab Node runs, while a lock remains (pass --force after a crash), or while the worktree has uncommitted changes (--force discards them). It deletes the worktree and the Node data of its profile and its lab Nodes (today in the OS state directory under the profile hash; see one home). It keeps the branch.

The main checkout keeps its own .cache/turbo. To share the cache with your dev worktrees, replace that directory with a link to the shared one.

Clean up

pnpm dev:clean            # what it would remove, and how much space
pnpm dev:clean --yes      # remove it
pnpm dev:clean --builds   # also this checkout's build outputs (dist, .expo, coverage…)

It fetches origin and removes each dev worktree whose work landed in origin/main, along with its dev Node data and its .cache (the worktree's MAIA_HOME included), and Git's records of worktree directories deleted by hand. Work landed in one of two ways, each checked against exact commits: the branch tip is an ancestor of refs/remotes/origin/main, or the tip is the head of a pull request GitHub merged into main (gh pr list --state merged). A squash merge leaves the tip outside origin/main's history, so only the merged pull request proves it. A matching tree does not: a revert or an empty commit gives the same tree. When gh cannot answer (missing, not logged in, offline, or origin not git@github.com:, https://github.com/ or ssh://git@github.com/ followed by owner/repo), the worktree stays with that reason. A branch with a commit past the merged head has not landed. --base takes origin/<branch> or a local branch; a local base counts the commits in its history, not pull requests, since GitHub merges into origin/<branch>. A name that resolves to no commit stops the run. Under the worktree's lab lock, right before Git removes it, dev:clean checks again that the path, the attached branch and its tip are the approved ones and that no work appeared, ignored files no build restores included. A worktree that changed since the plan stays, with the reason, as one the plan kept would: the run still exits 0, and it exits 1 only when a removal fails. Git then refuses modified or untracked files itself, and dev:clean deletes the wt/<name> branch at the approved tip and no other, never one that --branch named. A file or commit that arrives after that last check can be lost: dev:clean assumes an idle worktree. It keeps a worktree on the base branch, with uncommitted changes, with ignored files no build restores (such as .env), with a running or starting dev or lab Node, with no commits yet (a branch inside origin/main's history that nobody committed to), or with work not in origin/main, and says why; pnpm dev:worktree remove <name> removes such a worktree by hand. It never touches the main checkout's node_modules or the Turborepo cache.

Disk stays small without it: worktree dependencies are clones of the shared pnpm store (copy-on-write on APFS and Btrfs), and Turborepo prunes its cache past 7 days or 2 GB (turbo.json). pnpm store prune drops packages that no project on the machine uses.

Agents building the roadmap in parallel follow the unit-of-work, lane and shared-file rules in Maia builds Maia.

Dogfood lab

pnpm dev:lab runs one to four real Nodes beside your own, to try Maia with Codex and Claude Code through a gateway Connection to a local model proxy (D-133). It refuses the main checkout: run it in a dev worktree.

pnpm dev:lab up [--nodes <1..4>] [--ssh]  # build (cached), start detached Nodes, wait until ready; --ssh (Linux) adds a loopback sshd
pnpm dev:lab status                    # per Node: state, pid, profile, ports, loopback check, log, environment; its unit on Linux
pnpm dev:lab ssh check                 # per Node: greet its bridge over the lab sshd
pnpm dev:lab maia <n> [--] <args>      # this checkout's CLI against running lab Node <n>
pnpm dev:lab connect [--yes]           # preview; --yes adds the gateway Connection and approves the integrations
pnpm dev:lab scenario smoke --live     # per Node: one read-only Session and one turn per executor
pnpm dev:lab scenario stress [--concurrency <1..4>] [--turns <1..40>] [--inject http5xx=<n>,malformed=<n>,timeout=<n>] [--baseline <snapshot.json>]
pnpm dev:lab down [--purge]            # shut the Nodes down and wait for them to exit; --purge deletes the lab's data
  • Configuration is four variables in your shell, never in a file: MAIA_LAB_GATEWAY_URL, MAIA_LAB_GATEWAY_KEY_COMMAND (an absolute path to an executable file you own, mode 0700 or stricter, that prints the key), MAIA_LAB_CODEX_MODEL and MAIA_LAB_CLAUDE_MODEL. Only connect and scenario smoke need them, and the lab drops all four from its own environment once it has read them. The lab runs the key command with no shell and pipes its output to the CLI's standard input, so the key reaches the Node's custody file and nothing else. The key command gets the environment list below without HOME. A key command that reads under your home, or one you cannot make 0700, goes behind a wrapper of your own at mode 0700, outside the repository, that sets HOME and execs it (#!/bin/sh and HOME=/home/you exec /path/to/key-command). The lab names the mode of a refused key command and says when one exits without a key. While connect --yes runs, the gateway URL shows in the login command's arguments, because the CLI takes it nowhere else. The Connection commands (connection list, text and --json) and the login listing print a gateway Connection's base URL, because it is operator configuration, not a secret. Mask it in anything you keep; the lab's own evidence never holds it.
  • Isolation: each Node has its own profile, dev-lab-<n>-<digest> with the first 12 hex digits of the lab root's SHA-256, so two checkouts never share a profile or the runtime folder it names. A lab record that names other profiles, one copied from another checkout for instance, makes every lab command refuse. Each Node also has its own HOME and MAIA_HOME under .cache/dev/lab/<n>, and a disjoint share of the worktree's Session ports. Those shares split the whole range that pnpm dev:node uses in the same worktree, so run one or the other. A Node's state follows its own XDG_STATE_HOME on Linux and $HOME on macOS, until one home (RL3), so your ~/.maia, ~/.codex and ~/.claude stay untouched. On Linux that state home is ml-<6 hex> in /run/user/<uid>, the XDG_RUNTIME_DIR systemd-logind makes for you, and in no other folder. A coding Claude Code Session keeps its sandbox sockets beside the Node's runtime directory, and a path in the checkout is too long for them; the runtime directory under /run/user/<uid> fits the 107-byte limit for any real uid. Before it builds, up checks that the recorded XDG_RUNTIME_DIR is exactly /run/user/<uid>, with no trailing slash; that /run and /run/user are root's folders, not links, that nobody else can write; and that /run/user/<uid> is a folder of yours, not a link, with no group or other permissions. So nobody but root and you can change the way to a state home, as another user could under a shared TMPDIR. Every other lab command refuses a record that names another folder (see State of the lab). up also refuses a state home that is not a folder of yours with no group or other permissions. The lab makes the state home before its Node starts, so the Node's read denial of its own runtime folder (maia/ beside it) leaves the state home readable to its workers. A reboot, or logging out without loginctl enable-linger, clears that folder and the Nodes' state with it. --purge repeats the folder checks while /run/user/<uid> exists, removes only the state homes it found as folders of yours, and refuses a link or another user's folder. A Node and the CLI get only PATH, LANG, LC_ALL, TMPDIR, USER, LOGNAME and TERM from your environment, plus their own HOME, MAIA_HOME and MAIA_PORT_RANGE and, on Linux, the XDG_RUNTIME_DIR up saw and the XDG_STATE_HOME in it. It keeps a Node's runtime folder out of a shared /tmp, where a Claude Code Session's read denial refuses to start (read_denial_shared_ancestor). Their PATH starts with two folders of the lab: .cache/dev/lab/bin, whose maia (written at each up, mode 0700) runs this checkout's built CLI with the Node.js that ran up, so a Node's worker MCP runs this checkout's CLI and never an installed maia; then .cache/dev/lab/ssh/bin, the lab's ssh (see SSH fleet). Every Node listens on loopback; status checks it and exits 1 on a listener elsewhere. On macOS, up refuses a TMPDIR so long that a Node's control socket would pass the 100-byte limit: start from a shorter one. On Linux, both sockets sit in /run/user/<uid>, so TMPDIR plays no part: the deepest Claude Code sandbox socket fits the 107-byte limit, and up refuses a path that does not fit.
  • Private folders: each folder under .cache/dev/lab, where the lab keeps the scripts first on its PATH and its keys, must be yours with no write access for your group or others. Each folder above it, up to the folder that holds the checkout, must pass the Node's own rule for what it launches: yours or root's, and written by nobody else but your private group (a group named after you, with no other member, that no other user has as primary group). A sticky /tmp or a shared group fails. The lab checks this whenever it creates a lab folder or writes a script, and refuses otherwise; folders above the checkout's parent are not checked.
  • Links: the lab refuses a symbolic link on the way to any folder it creates or hands to a Node (the checkout's .cache and .artifacts included) and anywhere under a Node's HOME, MAIA_HOME or XDG_STATE_HOME, when it creates the folder and before every launch. The scan does not follow links, skips a Node that already runs, and allows the helper links Codex 0.163.0 makes in a Session's Codex home (<state>/harness/codex/<session>/home): apply_patch or applypatch in tmp/arg0-macos/codex-arg0<random>/ on macOS, and those or codex-linux-sandbox in tmp/arg0-linux/codex-arg0<random>/ on Linux. Each must link to the codex executable the Node resolves from its PATH (the first codex entry, which must be an executable file) or, when that is the npm launcher @openai/codex/bin/codex.js, to the native binary of that package (vendor/<target>/bin/codex, there or in its node_modules/@openai/codex-<platform>-<arch>), as npm lays it out; a platform package linked in from elsewhere is refused. The manifest, the results and the Node log refuse a link at the file itself. A process of your own user that swaps a link is out of scope for the whole window from preflight onward, between the scan and the launch as much as after it, and helper links are trusted as Codex's own.
  • State of the lab: .cache/dev/lab/lab.json records each profile and the TMPDIR and, on Linux, the XDG_RUNTIME_DIR up started the Nodes under. Every lab command, dev:worktree remove and dev:clean find a Node's files through that record, so they find the Node whatever TMPDIR the caller has. On Linux, up refuses a lab recorded without XDG_RUNTIME_DIR, whose Nodes would keep their runtime folders and state under TMPDIR: run down --purge, then up. Every lab command refuses a record whose XDG_RUNTIME_DIR is not exactly /run/user/<uid>: stop that lab's Nodes by hand, then delete .cache/dev/lab. One lock, .cache/dev/lab.lock, orders up, down, dev:worktree remove and the removals of dev:clean. A lock is never taken over: a live holder stops the next command with a message, and the lock of a command that crashed is reported as stale with the one command that removes it (rm -r on the lock directory, once no lab command runs).
  • Linux units: the Maia process host creates each worker's cgroup beside the Node's own and fails closed with cgroup_delegation_unavailable when that parent is not delegated to you, as under a login session's scope, and with landlock_scoped_unavailable on a kernel below Landlock ABI 6 (the helper's own diagnostic is landlock_unavailable) (external executors). So on Linux up runs each Node as a transient systemd user service, maia-<profile>.service, with Delegate=yes and DelegateSubgroup=node (the Node in <unit>/node, its workers beside it in the unit), started through systemd-run --user and env -i with the allow-listed environment alone, its output appended to the Node's log. status shows each unit and its state. up and down first check every lab unit and refuse, having built, started or stopped nothing, unless each is active and systemd records it running from that Node's lab folder (so it is this lab's, not another clone's), or inactive or failed. They refuse too when systemctl --user cannot answer or a unit is between states, and when a Node's live descriptor has no active unit of this lab behind it (a Node someone started by hand under a lab profile, for one): they name its pid and send it nothing. down then stops each unit with systemctl --user stop (KillMode=mixed): SIGTERM goes to the Node alone, which stops its own workers, and systemd kills the remaining processes once the Node exits, or after 60 s (TimeoutStopSec). Without a user manager (systemctl --user does not answer) up and down refuse before they change anything, with that hint: log in through a session that starts one, or run loginctl enable-linger. A unit is transient: systemd drops it once its Node stops, so systemctl --user start cannot bring it back. pnpm dev:lab up starts every recorded Node that is not running again, with its profile and data, and leaves the ones running in their units alone. A unit that is active while its Node has no live descriptor (a start that timed out) makes up refuse before it builds, and again before it starts any Node if the descriptor went away during the build; run down, then up. macOS keeps detached child processes.
  • SSH fleet: up --ssh also starts a throwaway sshd of your own user on 127.0.0.1 (port first + 4), so one CLI can reach each lab Node over real SSH as it reaches a server (D-141). It runs on Linux only, since sshd runs in a systemd user unit: on macOS, or where no user manager answers, up --ssh refuses before it starts anything, and lab Nodes without --ssh run as before. It uses the system OpenSSH (/usr/bin/ssh, /usr/sbin/sshd, /usr/bin/ssh-keygen). Fresh host and client keys at each start, authorized_keys (the client key, restrict, from 127.0.0.1), known_hosts, both configurations and its log live in .cache/dev/lab/ssh (folders 0700, files 0600); nothing reads or writes ~/.ssh, /etc/ssh or your ssh configuration. The lab creates sshd.log before sshd starts and refuses a link there, since sshd would write through it. sshd runs without PAM, passwords, forwarding or a terminal, with StrictModes on (it refuses the key under a folder others can write and names it in sshd.log), and gives a session the HOME .cache/dev/lab/ssh/home. ssh check opens each Node's bridge over SSH and checks that it greets ready as that Node's profile. Its ssh, in .cache/dev/lab/ssh/bin, runs the system one with -F and the lab's configuration, which maps lab-<n> to the sshd and checks the host key strictly against the lab's known_hosts, whatever HOME the caller has. up writes it before it launches a Node, with or without --ssh, and it refuses rather than run the system ssh with your ~/.ssh until up --ssh has written the lab's configuration; a link at the configuration or the wrapper is refused too. Scripts and configurations are published whole, by rename. Every lab process finds it first on PATH, so the ssh a lab Node spawns itself (to greet a Node it registers) and the one pnpm dev:lab maia spawns for --node take the same configuration, keys and known_hosts. Put it first on PATH yourself only for a CLI or Node outside the lab (pnpm maia, pnpm dev:node). ssh lab-<n> <checkout>/.cache/dev/lab/<n>/bridge runs maia-node bridge for Node <n> in that Node's own environment (env -i). sshd runs in the transient user unit maia-dev-lab-sshd-<digest>.service, named after a digest of the lab root, since two clones can share a port slot. up --ssh and down check that unit as they check a Node's, its folder .cache/dev/lab/ssh, before they build, start or stop anything. down ends it with every session it accepted, an idle bridge included, and checks the unit ended before --purge removes anything. The lab signals nothing it cannot tie to that unit: an sshd outside it, which a lab from an earlier branch may have started, makes up --ssh and down refuse and name its pid. Close your lab ssh clients, stop that pid, then run down --purge. When ps cannot list a saved sshd pid that is still alive, both refuse too. status reports the sshd with its loopback check and its unit, down stops it with the Nodes, --purge deletes its keys, and dev:worktree remove and dev:clean refuse while it runs. The worktree path must hold only letters, digits, ., _, - and /; up --ssh checks it and OpenSSH before it builds anything. up registers no Node: to address Node 2 from Node 1, preview the registration and confirm it, pnpm dev:lab maia 1 -- --json node register lab-2 --launcher <checkout>/.cache/dev/lab/2/bridge --name lab-2 --dry-run | pnpm dev:lab maia 1 -- node register lab-2 --launcher <checkout>/.cache/dev/lab/2/bridge --name lab-2 --confirm-stdin, then pnpm dev:lab maia 1 -- --node lab-2 status.
  • By hand: pnpm dev:lab maia <n> [--] <args> runs this checkout's built CLI (apps/cli/dist/development/cli.js, rebuilt when its sources changed) against running lab Node <n>: with --profile, that Node's environment and its folder under .cache/dev/lab. It passes your arguments and standard input as they are and exits with the CLI's status. pnpm maia always reaches the worktree's own Node. status prints the HOME, MAIA_HOME, MAIA_PROFILE, TMPDIR, recorded XDG_RUNTIME_DIR and XDG_STATE_HOME of each Node.
  • Smoke is capped at one turn per executor on each Node, in a fixture repository, and never writes. It makes no call of its own to the gateway: every call is a Session the Node starts. It records IDs, durations and token usage (or unavailable with the reason) in .artifacts/lab/<run>, never the key, the host or the key command's path. A case is passed, failed or blocked (integration not approved, proxy unreachable, provider refusal or outage); the command exits 1 unless every case passed. The fixture registers under a command ID that belongs to the Node, so the Project is always the generated fixture.
  • Stress: scenario stress needs no gateway configuration and reads no key. It starts a fake model gateway on loopback (the worktree's first + 3 port) that answers the Responses API stream real Codex reads, logs each running Node in to it with a fixed offline key, and runs a bounded load of read-only Codex turns (--concurrency Sessions at once on each Node, at most 4, and --turns per Node, at most 40; defaults 2 and 6). --inject marks that many of the turns on each Node to meet a 5xx (failed, provider_unavailable), a malformed stream (failed, runtime_failed) or a stalled stream. A stalled turn ends when the 60 s wait returns with the turn still running, the run stops its Session and the Node then reports outcome-unknown; without all three it is unexpected, like any other outcome that is not the injected one. After it logs a Node in, the run checks that the Node's lab-stress Connection is the enabled gateway Connection to this run's loopback URL, and refuses the Node otherwise. Each create carries a command ID of its own (run, Node and ordinal). The run ID is the start time plus a random suffix, so two runs never share a command ID. When the CLI names no Session for a create (a timeout, an interrupt, a killed CLI, or outcome_unknown, the stage unknown), the Node may still commit it later, because a handler it dispatched outlives its client. The run then reads that command ID's receipt again until one deadline 5 s after the first lookup; the lookups and the pauses between them share it, and each lookup's timeout and each pause stop at what is left. A receipt still absent at the deadline leaves the command ID unresolved. A refusal the Node itself gave is final and is not retried. A Session is the run's to stop as soon as its ID exists, with or without a turn. When a worker fails or the run gets SIGINT or SIGTERM, nothing new starts; the run cancels the waits in progress, joins every worker, stops and checks every Session it created, then closes the gateway, then releases the lab lock. A Session that does not stop within the bounded time is listed as unresolved. The run records turn latency (median and p95 in the snapshot, p99 in the summary), the throughput of verified terminal turns, and the peak RSS and CPU time of each Node process (not its Codex children) as a lab suite snapshot in .artifacts/lab/stress/<run>/; --baseline compares it in stored mode when the workload matches. After teardown it compares the processes below the Nodes and the listening ports of the run's own processes with the start of the run, by identity (process ID and start time), not by count. Each Node must keep the identity it started with, and a port counts for a process only while that process keeps the identity the run saw. A Node found replaced stays a failure but stops being a root: the run attributes no child and no port of its replacement to the run. The command exits 1 on an unexpected outcome, an unresolved Session or create, an interrupt, a failed ps or lsof or a ps row it cannot read (a missing lsof match is not a failure), a Node that leaves the process table or is replaced, or a leftover. It exercises the Node, its Sessions and the real Codex process against a fake; it does not exercise a real model, real token counts or the Claude Code executor.
  • Teardown: on Linux down stops each Node's unit (see Linux units). On macOS it asks each running Node to shut down through the SDK that up built, which first checks the Node's process identity, then waits for the process to exit. It never builds, so a build error cannot keep a Node running; with no built SDK it stops with a message. A Node whose lock has no live descriptor stays untouched and is reported. --purge also deletes the key's custody file with the lab root and the Nodes' state homes and runtime directories. dev:worktree remove and dev:clean refuse while a lab Node runs, or while a lab command holds the lock.

Dev deployment

  • Local: pnpm dev:node in a dev worktree builds the Node (cached) and runs it in the foreground under the worktree's profile. Ctrl-C, or SIGTERM to the pnpm dev:node process, stops it.
  • Real profile: that Node serves maia only inside its worktree. To drive workers from a Claude Code or Codex session started in another repository, run a Node on the real profile (~/.maia) from the main checkout: pnpm --filter "@maiaos/node..." build, then node services/node/dist/bin/maia-node.mjs --desktop.
  • Remote: pnpm dev:remote <ssh-host> [--dir <path>] [--dry-run] sends the tracked files (git ls-files, working-tree content) to <path>/releases/<time>-<commit> on the host, checks for Node 24.22.1 and pnpm 11.20.0 there, installs the frozen lockfile, builds the Node and CLI, points <path>/current at the release and keeps three releases. It never starts or stops a Node; it prints the command to run one under a dev profile and its own MAIA_HOME.

Measure performance

pnpm metrics                                # size, health and CLI startup of this checkout
pnpm metrics:compare                        # this checkout against its merge base with origin/main
pnpm metrics:compare --base origin/main~3   # against any ref, built in a temporary worktree
pnpm metrics --suite node,observation       # the existing Node benchmarks (minutes)

pnpm metrics writes .artifacts/metrics/<sha>.json and prints each value with its owner. pnpm metrics:compare measures both trees on this machine, alternating them for wall time, and prints the change, a 95% confidence interval and a verdict per metric; it exits 1 when a metric fails its policy. Wall time on a loaded machine is noisy, so compare it this way only. The catalog, thresholds, CI job and how to accept a regression with a Metrics-Accepted: commit trailer are in benchmarks.

CI

                 scope (hosted): finds the base, decides which jobs run
                                   │
   ┌────────────┬─────────────┬────┴────────┬───────────────┬───────────────┬───────────────┐
 quality:     test: tooling  test: Node    client E2E     Linux process   macOS process   metrics
 format,      + packages     1/2 and 2/2   (client        host (native    host (Node      (push to main,
 policy, lint (any package)  (Node         changed)       sources, CI)    changed)        any package)
 typecheck,                  changed)
 build (saves
 the Turborepo
 cache)

Each job runs only when the change can affect it. scripts/quality/change-scope.mjs decides once, in the scope job, and logs the reason; a skipped job shows as skipped. The jobs start together when it ends, and each builds what it needs: the tests do not wait for the quality job, so a lint failure does not skip them. A failed scope job fails every job below it instead of skipping them, so a skipped check always means the change cannot affect it. The change is the diff from a base:

  • Pull request: the merge commit's first parent, so the diff is the pull request.
  • Push to main: the newest commit whose CI run passed, read from the Actions API. A cancelled or failed push's changes stay in scope until a run passes. Without such a commit in the last 100, or if it is not an ancestor, everything runs.

Everything under docs/ selects no package, and neither does other Markdown, except the Markdown that code reads or ships: Skill files (SKILL.md), the CLI guidance, plugins, test fixtures and each package's root README.md, which its tarball carries. Any other path inside a package selects that package, its dependents, and any package whose task declares the path as a $TURBO_ROOT$ input in turbo.json; any other path outside the packages selects every package. A documentation-only change runs only the scope, quality and growth jobs in CI, and only format, the lint and documentation policies, registry parity and the growth budget before a push; those checks run for every change. Jobs restore the pnpm store and the newest Turborepo cache, so unchanged packages are cache hits; only the quality job saves it. The E2E job installs the Playwright browsers while the client builds. The scope and growth jobs run on GitHub-hosted runners, which start in about 2 seconds and leave the self-hosted pool to the builds and tests; the macOS job installs only the Node's dependencies. The metrics job compares each push with the last passing push's snapshot and fails on an exact metric past its policy (benchmarks). Publishing is a separate workflow; see release.

Agent gates (planned)

The DX track gives an agent the same loop a person uses, with output it can parse. It adds no package: Node built-ins, Git, GitHub-native features and the tools above.

pnpm verify --plan          # what runs for this change, and why (JSON)
pnpm verify                 # run it; findings as NDJSON, exit code is the verdict
pnpm verify --base origin/main --report .artifacts/verify.ndjson

One record per finding, the same in a terminal, a hook and CI (where it becomes an annotation):

{
  "gate": "lint",
  "rule": "maia-effect/no-promise",
  "file": "services/node/src/x.ts",
  "line": 42,
  "severity": "error",
  "message": "…",
  "fix": "…"
}
  • Selection. Turborepo's affected packages plus the existing change scope; a root or scripts/ change still runs everything.
  • Time. Each check has a hang timeout that fails. M0 records dx.verify.s, the hook times and CI wall time as trends that warn; a blocking timing budget needs a reference machine (benchmarks).
  • Tests. No automatic retries. A flaky test goes into the quarantine file with its issue and an expiry; an expired entry fails the gate. A bug fix carries a test that fails without it.
  • Security in CI. The staged secret guard and blob-size cap rerun over the pull request; pnpm audit fails on high and critical advisories without a recorded exception; the lint policy keeps workflows at no default token permissions and SHA-pinned actions, with no pull_request_target.
  • Evidence. The pull request template asks for the unit ID, the commands run and their results, metric deltas and AI disclosure; a check reads the body.
  • Merging. The maintainers' own sessions commit straight to main behind the pre-push gate; worker units land through pull requests with one aggregate required check (D-125). A red main branch is reverted first, bisected, then re-landed with a regression test.

Approved (D-126): zizmor for workflow files and cargo-deny for the Desktop's Rust crates, pinned and CI-only, and GitHub Secret Protection's push protection. Code scanning and dependency review are not used.

Prior art: Kubernetes' make verify and zero-flake policy, Rust's revert and performance triage rules, Node.js's benchmark comparison, Turborepo's --affected and --dry=json, and GitHub's merge queue and Actions hardening guide.

Troubleshooting

  • dev:doctor fails "repository-local hooks path": run pnpm dev:setup.
  • A hook says the dependencies are not installed, or a Git client cannot find node: run pnpm dev:setup; the hooks run node from the PATH that Git sees, so a desktop Git client needs Node on its PATH.
  • dev:worktree create cannot install offline: it retries online; a lockfile change needs network access once.
  • dev:worktree remove reports a remaining lock: the dev Node crashed or is starting; stop it, or pass --force.
  • A dev worktree's directory was deleted by hand: run git worktree prune. Its Node data stays in the OS state directory under the profile hash.
  • check:changed cannot find origin/main: it checks everything; pass --base <ref> to narrow it.
  • Tooling tests fail locally with "toolchain npm could not be resolved": node comes from pnpm's runtime, which has no npm; pnpm dev:doctor reports it. Put an official Node 24.22.1 install first on PATH.
  • A check you expected did not run in check:changed: run it with --dry-run to see the scope, add the missing input to turbo.json, or run pnpm check.
  • Tests, MAIA_HOME and Git hooks: every node:test suite (pnpm test:tooling and the package test scripts) preloads scripts/lib/test-environment.mjs. It drops every GIT_* variable, which a hook exports (GIT_DIR, GIT_INDEX_FILE), so tests act on their own fixture repositories, and points MAIA_HOME at a throwaway directory. A bare node --test of one file skips it. The Vitest suites (Node, protocol) do not preload it yet; Turborepo passes MAIA_HOME to them, so MAIA_HOME=$(mktemp -d) pnpm test isolates them from ~/.maia. Tests run Git only through runIsolatedGit or initializeFixtureRepository (scripts/lib/process.mjs); pnpm lint:policy rejects other Git spawns.
  • Linux Desktop builds: install the webkit2gtk-4.1 and openssl development packages.
  • Pi RPC tests fail with invalid_request on Linux: the checkout's node_modules predates packageImportMethod: clone-or-copy and still holds hard links to the pnpm store, which the Node refuses for the bundled Pi. pnpm install keeps an existing tree, so delete every node_modules directory and install again once.

Imported from Maia OS docs at a2175bb