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
| Command | What it does |
|---|---|
pnpm maia <args> | Runs this checkout's CLI and rebuilds it when its source is newer. |
pnpm check:changed | Fast gate: only the checks your changes since origin/main can affect, tests included. The pre-push hook runs it. |
pnpm check:quality | Format, policy, lint, typecheck, build, through the Turborepo cache. |
pnpm check:growth | The change's net source lines against the growth budget. |
pnpm dev:clean | Lists finished dev worktrees and build outputs to remove; --yes removes them (clean up). |
pnpm check | Full gate: check:quality plus pnpm test. |
pnpm build | Builds every package through the Turborepo cache. |
pnpm metrics:compare | Size, 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_CONCURRENCYandVITEST_MAX_WORKERSraise them for one run. - Where
flockexists (Linux), one gate runs at a time on a host: a second waits on a lock file in the temporary directory. - ESLint and
tsckeep caches innode_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:cleanonce 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.
| Hook | What it checks | Measured time |
|---|---|---|
pre-commit | Staged 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-msg | A Conventional Commits subject, type(scope)!: summary, in English. Git's merge, revert and fixup! subjects pass. | under 0.1 s |
pre-push | pnpm 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:doctorwarns when this clone does not sign. It reads an update's range asremote..localand 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-verifydoes 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 --writethengit add, orgit add -pfor 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 beforepnpm buildthe 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-verifyor 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/mainshows that CI judged the commits. The stamp below is the fast path. - Gate stamp: a
pnpm check:changedthat 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 variablesturbo.jsonhashes 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 inglobalEnvor theenvof theturbo.jsontask that runs them. A test checks that list against theMAIA_switchesturbo.jsonpasses through,NODE_OPTIONSandPATH; it cannot see a variable it does not name. The stamp hashesPATHwithout repeats and without the directories this checkout's Git and pnpm put in front of it: Git's default exec directory, thenode_modules/.binof the root and of each package, and thenode-gyp-binof the pnpm thatnpm_execpathnames, whichpnpm runadds. Any other directory counts, another worktree'snode_modules/.binand aGIT_EXEC_PATHoverride 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--branchnames 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/turboon macOS or$XDG_CACHE_HOME/maia-os/turbo(MAIA_DEV_TURBO_CACHEoverrides it); - its own Maia profile
dev-<name>-<random>and its ownMAIA_HOMEat.cache/dev/maia-home. The record sits in the worktree's Git metadata (.git/worktrees/<id>/maia-dev-isolation.json), sogit clean -fdxkeeps it.pnpm maiaandpnpm dev:nodeapply both, so the worktree never reads or writes your real~/.maiaor reaches your default-profile Node. Runpnpm maia loginthere if the worktree needs accounts.--basemust 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 block | Used by | Override |
|---|---|---|
| first | Desktop dev server (pnpm dev:desktop) | --port |
| first + 1 | client E2E server | MAIA_CLIENT_E2E_PORT |
| first + 3 | offline gateway of pnpm dev:lab scenario stress | none |
| first + 4 | loopback sshd of pnpm dev:lab up --ssh | none |
| first + 10 – last | ports 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_MODELandMAIA_LAB_CLAUDE_MODEL. Onlyconnectandscenario smokeneed 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 withoutHOME. 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 setsHOMEandexecs it (#!/bin/shandHOME=/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. Whileconnect --yesruns, 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 theloginlisting 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 ownHOMEandMAIA_HOMEunder.cache/dev/lab/<n>, and a disjoint share of the worktree's Session ports. Those shares split the whole range thatpnpm dev:nodeuses in the same worktree, so run one or the other. A Node's state follows its ownXDG_STATE_HOMEon Linux and$HOMEon macOS, until one home (RL3), so your~/.maia,~/.codexand~/.claudestay untouched. On Linux that state home isml-<6 hex>in/run/user/<uid>, theXDG_RUNTIME_DIRsystemd-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,upchecks that the recordedXDG_RUNTIME_DIRis exactly/run/user/<uid>, with no trailing slash; that/runand/run/userare 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 sharedTMPDIR. Every other lab command refuses a record that names another folder (see State of the lab).upalso 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 withoutloginctl enable-linger, clears that folder and the Nodes' state with it.--purgerepeats 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 onlyPATH,LANG,LC_ALL,TMPDIR,USER,LOGNAMEandTERMfrom your environment, plus their ownHOME,MAIA_HOMEandMAIA_PORT_RANGEand, on Linux, theXDG_RUNTIME_DIRupsaw and theXDG_STATE_HOMEin 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). TheirPATHstarts with two folders of the lab:.cache/dev/lab/bin, whosemaia(written at eachup, mode 0700) runs this checkout's built CLI with the Node.js that ranup, so a Node's worker MCP runs this checkout's CLI and never an installedmaia; then.cache/dev/lab/ssh/bin, the lab'sssh(see SSH fleet). Every Node listens on loopback;statuschecks it and exits 1 on a listener elsewhere. On macOS,uprefuses aTMPDIRso 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>, soTMPDIRplays no part: the deepest Claude Code sandbox socket fits the 107-byte limit, anduprefuses a path that does not fit. - Private folders: each folder under
.cache/dev/lab, where the lab keeps the scripts first on itsPATHand 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/tmpor 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
.cacheand.artifactsincluded) and anywhere under a Node'sHOME,MAIA_HOMEorXDG_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_patchorapplypatchintmp/arg0-macos/codex-arg0<random>/on macOS, and those orcodex-linux-sandboxintmp/arg0-linux/codex-arg0<random>/on Linux. Each must link to thecodexexecutable the Node resolves from itsPATH(the firstcodexentry, 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 itsnode_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.jsonrecords each profile and theTMPDIRand, on Linux, theXDG_RUNTIME_DIRupstarted the Nodes under. Every lab command,dev:worktree removeanddev:cleanfind a Node's files through that record, so they find the Node whateverTMPDIRthe caller has. On Linux,uprefuses a lab recorded withoutXDG_RUNTIME_DIR, whose Nodes would keep their runtime folders and state underTMPDIR: rundown --purge, thenup. Every lab command refuses a record whoseXDG_RUNTIME_DIRis not exactly/run/user/<uid>: stop that lab's Nodes by hand, then delete.cache/dev/lab. One lock,.cache/dev/lab.lock, ordersup,down,dev:worktree removeand the removals ofdev: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 -ron 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_unavailablewhen that parent is not delegated to you, as under a login session's scope, and withlandlock_scoped_unavailableon a kernel below Landlock ABI 6 (the helper's own diagnostic islandlock_unavailable) (external executors). So on Linuxupruns each Node as a transient systemd user service,maia-<profile>.service, withDelegate=yesandDelegateSubgroup=node(the Node in<unit>/node, its workers beside it in the unit), started throughsystemd-run --userandenv -iwith the allow-listed environment alone, its output appended to the Node's log.statusshows each unit and its state.upanddownfirst check every lab unit and refuse, having built, started or stopped nothing, unless each isactiveand systemd records it running from that Node's lab folder (so it is this lab's, not another clone's), orinactiveorfailed. They refuse too whensystemctl --usercannot 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.downthen stops each unit withsystemctl --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 --userdoes not answer)upanddownrefuse before they change anything, with that hint: log in through a session that starts one, or runloginctl enable-linger. A unit is transient: systemd drops it once its Node stops, sosystemctl --user startcannot bring it back.pnpm dev:lab upstarts 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) makesuprefuse before it builds, and again before it starts any Node if the descriptor went away during the build; rundown, thenup. macOS keeps detached child processes. - SSH fleet:
up --sshalso starts a throwaway sshd of your own user on127.0.0.1(portfirst + 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 --sshrefuses before it starts anything, and lab Nodes without--sshrun 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/sshor your ssh configuration. The lab createssshd.logbefore 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 insshd.log), and gives a session theHOME.cache/dev/lab/ssh/home.ssh checkopens each Node's bridge over SSH and checks that it greetsreadyas that Node's profile. Itsssh, in.cache/dev/lab/ssh/bin, runs the system one with-Fand the lab's configuration, which mapslab-<n>to the sshd and checks the host key strictly against the lab'sknown_hosts, whateverHOMEthe caller has.upwrites it before it launches a Node, with or without--ssh, and it refuses rather than run the system ssh with your~/.sshuntilup --sshhas 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 onPATH, so the ssh a lab Node spawns itself (to greet a Node it registers) and the onepnpm dev:lab maiaspawns for--nodetake the same configuration, keys andknown_hosts. Put it first onPATHyourself only for a CLI or Node outside the lab (pnpm maia,pnpm dev:node).ssh lab-<n> <checkout>/.cache/dev/lab/<n>/bridgerunsmaia-node bridgefor Node<n>in that Node's own environment (env -i). sshd runs in the transient user unitmaia-dev-lab-sshd-<digest>.service, named after a digest of the lab root, since two clones can share a port slot.up --sshanddowncheck that unit as they check a Node's, its folder.cache/dev/lab/ssh, before they build, start or stop anything.downends it with every session it accepted, an idle bridge included, and checks the unit ended before--purgeremoves 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, makesup --sshanddownrefuse and name its pid. Close your lab ssh clients, stop that pid, then rundown --purge. Whenpscannot list a saved sshd pid that is still alive, both refuse too.statusreports the sshd with its loopback check and its unit,downstops it with the Nodes,--purgedeletes its keys, anddev:worktree removeanddev:cleanrefuse while it runs. The worktree path must hold only letters, digits,.,_,-and/;up --sshchecks it and OpenSSH before it builds anything.upregisters 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, thenpnpm 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 maiaalways reaches the worktree's own Node.statusprints theHOME,MAIA_HOME,MAIA_PROFILE,TMPDIR, recordedXDG_RUNTIME_DIRandXDG_STATE_HOMEof 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
unavailablewith the reason) in.artifacts/lab/<run>, never the key, the host or the key command's path. A case ispassed,failedorblocked(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 stressneeds no gateway configuration and reads no key. It starts a fake model gateway on loopback (the worktree'sfirst + 3port) 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 (--concurrencySessions at once on each Node, at most 4, and--turnsper Node, at most 40; defaults 2 and 6).--injectmarks 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 stillrunning, the run stops its Session and the Node then reportsoutcome-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'slab-stressConnection 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, oroutcome_unknown, the stageunknown), 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 alabsuite snapshot in.artifacts/lab/stress/<run>/;--baselinecompares 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 failedpsorlsofor apsrow it cannot read (a missinglsofmatch 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
downstops each Node's unit (see Linux units). On macOS it asks each running Node to shut down through the SDK thatupbuilt, 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.--purgealso deletes the key's custody file with the lab root and the Nodes' state homes and runtime directories.dev:worktree removeanddev:cleanrefuse while a lab Node runs, or while a lab command holds the lock.
Dev deployment
- Local:
pnpm dev:nodein a dev worktree builds the Node (cached) and runs it in the foreground under the worktree's profile. Ctrl-C, or SIGTERM to thepnpm dev:nodeprocess, stops it. - Real profile: that Node serves
maiaonly 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, thennode 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>/currentat 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 ownMAIA_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 auditfails on high and critical advisories without a recorded exception; the lint policy keeps workflows at no default token permissions and SHA-pinned actions, with nopull_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
mainbehind 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:doctorfails "repository-local hooks path": runpnpm dev:setup.- A hook says the dependencies are not installed, or a Git client cannot
find
node: runpnpm dev:setup; the hooks runnodefrom thePATHthat Git sees, so a desktop Git client needs Node on itsPATH. dev:worktree createcannot install offline: it retries online; a lockfile change needs network access once.dev:worktree removereports 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:changedcannot findorigin/main: it checks everything; pass--base <ref>to narrow it.- Tooling tests fail locally with "toolchain npm could not be resolved":
nodecomes from pnpm's runtime, which has no npm;pnpm dev:doctorreports it. Put an official Node 24.22.1 install first onPATH. - A check you expected did not run in
check:changed: run it with--dry-runto see the scope, add the missing input toturbo.json, or runpnpm check. - Tests,
MAIA_HOMEand Git hooks: everynode:testsuite (pnpm test:toolingand the package test scripts) preloadsscripts/lib/test-environment.mjs. It drops everyGIT_*variable, which a hook exports (GIT_DIR,GIT_INDEX_FILE), so tests act on their own fixture repositories, and pointsMAIA_HOMEat a throwaway directory. A barenode --testof one file skips it. The Vitest suites (Node, protocol) do not preload it yet; Turborepo passesMAIA_HOMEto them, soMAIA_HOME=$(mktemp -d) pnpm testisolates them from~/.maia. Tests run Git only throughrunIsolatedGitorinitializeFixtureRepository(scripts/lib/process.mjs);pnpm lint:policyrejects other Git spawns. - Linux Desktop builds: install the
webkit2gtk-4.1andopenssldevelopment packages. - Pi RPC tests fail with
invalid_requeston Linux: the checkout'snode_modulespredatespackageImportMethod: clone-or-copyand still holds hard links to the pnpm store, which the Node refuses for the bundled Pi.pnpm installkeeps an existing tree, so delete everynode_modulesdirectory and install again once.
Imported from Maia OS docs at a2175bb