Documentation

Architecture

How conveyor.ai (internally the factory) is put together: what runs where, how a project gets stood up and connected, what lands in a project container versus the project's repo, and what the factory commits versus what it deliberately never does.

The product is conveyor.ai; the code, CLI helpers and data dirs say factory. Both names appear below and mean the same thing.

1. The core factory

One long-running container manages N projects. It is not a per-project runner — it is deployed once and holds all the state.

                 ┌──────────────────── factory container ─────────────────────┐
   browser ─────▶│  dashboard (static JS) + REST + SSE                        │
                 │  SQLite (/data/factory/factory.db)                          │
                 │  heartbeat engine  ·  container pool  ·  stage proxy        │
                 └────────┬──────────────────────────────────▲────────────────┘
                          │ SSH (launch + setup)              │ HTTP (callbacks)
                 ┌────────▼─────────┐              ┌──────────┴───────┐
                 │ AIF-12's cont.   │   ......     │ AIF-13's cont.   │
                 │ tools run here   │              │ tools run here   │
                 └──────────────────┘              └──────────────────┘
                  one container per ISSUE, from a capped pool (§2)

Two channels, deliberately asymmetric:

  • SSH — outbound, control only. The factory SSHes into a project to install helpers, run a tool's setup script, and launch a tool detached (setsid, fds to /dev/null). It never holds the connection open for the duration of a run.
  • HTTP — inbound, all the data. Once running, the tool calls back to the factory at FACTORY_URL with output chunks, heartbeats, progress, status changes, comments and input requests. All best-effort with retry, so a tool survives a factory restart mid-run.

That split is why only project→factory HTTP reachability and factory→project SSH are required; put both on a shared Docker network (FACTORY_NETWORK) and they find each other by DNS alias.

Processes inside the container

scripts/container-init.sh is the entrypoint. As root it installs the operator SSH key, persists sshd host keys on the volume, starts sshd on :22, adds dev to the docker-socket group, then execs the server as unprivileged dev (factory/server/src/main.ts).

The server boots in this order: open+migrate SQLite → ensure the outbound SSH keypair → seed declarative config only if the DB is empty → reconcile in-flight runs and stages → start HTTP → start the heartbeat.

The heartbeat engine

src/engine.ts beats on an interval (HEARTBEAT_SEC, default 15). Beats never overlap — but the guard expires (beatMayStart), because a guard without one turns a single wedged beat into a permanently silent engine.

Each beat first does the factory-wide work, in this order:

  1. reap runs — finalize runs whose heartbeat went stale, whose process is gone (verified over SSH), or that are superseded (the issue already advanced past the run's status_version).
  2. the pool sweeps, and their order is load-bearing (see the pool section below): vanished containers first, since everything after it assumes a row points at something real; then retry pending persists; then reclaim abandoned holds; then reap idle containers on a billed backend (after the hold sweep, so one handed back this beat is considered at once); then sweep orphaned instances, throttled to ORPHAN_SWEEP_SEC — "orphaned" meaning stamped with THIS factory's instance id (FACTORY_INSTANCE_ID, else generated into <data dir>/instance-id, AIF-656), so two factories sharing a docker socket or fly org never sweep each other; unstamped legacy instances are never swept. Orphaned stage/conversation session containers are released last.
  3. the epic scheduler — deliberately before the project pass, so an issue promoted now launches in this same beat.
  4. dependency waits — let issues back in once their blockers land. The kickstart hook already does this the instant a blocker goes terminal; this is the safety net for moves no status change announces.

Then, per project (bounded concurrency, maxProjectConcurrency):

  1. ping over SSH — sets the reachability flag; skip an offline project.
  2. auto-setup / re-provision — hashes the provisioning inputs (the helper script set + each pipeline tool's setup_script + a layout version). If the hash drifted from what was last applied, re-run setup over SSH. A pure local hash compare when nothing changed, so it costs nothing on a normal beat.
  3. reconcileProjectImage to pick up a changed .factory/development/Dockerfile. Fire-and-forget.
  4. schedule — walking the project's issues oldest-waiter-first, so the issue that has waited longest on a full step claims the next slot. For each issue whose status matches a pipeline step, launch the step's tool, subject to: the dependency gate, a container slot from the pool, one run per (step, status_version) so the same status can't re-fire, the step's max and max_per_project concurrency, and a circuit breaker against status-loop ping-pong.

Linear sync, the GitHub Actions indicator, the deployed-to-production check and the token check ride the end of the beat, each behind its own try/catch — an outage in any of them must never stop reaping or scheduling.

The deployed mark (AIF-459). src/deploystatus.ts asks the GitHub Deployments API which commit is live in each project's production environment (params.production_environment, default production, matched case-insensitively) and flags every merged issue whose merge commit is in that commit's history — issues.deployed, rendered as a small mark on issue rows and the issue detail header. Both expensive questions are answered in bulk rather than per issue: one page of the project's closed PRs resolves a hundred merge commits at once (matched to issues by branch name), and ancestry is membership in one page of the deployed commit's history rather than a compare call each. A candidate is only re-examined once the project's deployed sha changes, so the steady state is two API calls per project per interval. A project whose pipeline creates no GitHub Deployments is a silent no-op — no mark, no error — which is why this repo's own deploy.yml records one (record-deployment) and every other project must do the same for its issues to be marked.

Launching is transition-triggered, not polled: output and status arrive over HTTP as they happen.

Domain model

ConceptWhat it is
Toolsetup script + exec command + param definitions. Factory-scoped (shared) or project-scoped (shadows a factory tool of the same name).
WorkflowA named bundle of statuses + a pipeline. Factory workflows are shared; each project also has a private custom one.
ProjectA remote container: SSH info, root_dir, data_folder, params, issue-key prefix, and the workflow it uses.
Pipeline stepon: <status> → run tool with params, at most max at once.
IssueKey (AIF-12), title, body, status, type, properties, documents, comments, plus a derived exec state (running/needs_input/pending/offline/none).

Configuration: YAML seeds, the DB rules

config/tools|workflows|projects/*.yaml is a bootstrap seed, imported only when the DB is empty (or on an explicit POST /api/reload). After that the SQLite DB is the source of truth, because the dashboard writes to it directly — so a restart never clobbers UI-managed data. factory.sh redeploy syncs the repo's config tree into the live factory and reloads it after the health check (upsert-only, best-effort).

Factory-side state (/data, the factory-data named volume)

/data/factory/
  factory.db          SQLite: projects, tools, workflows, issues, runs, users, conversation threads (`terminal_*` tables)
  config/             the pushed YAML tree
  ssh/                the factory's outbound keypair (+ known_hosts)
  ssh-hostkeys/       sshd host keys, so the fingerprint survives rebuilds
  runs/<run>/         factory-side copy of each run's output.log
  claude/             the factory's own claude login (conversation agent)
  terminal/factory/   the factory conversation's scratch + clones

Everything else in the container is rebuilt from the image. Run several factories on one host by giving each a unique FACTORY_NAME and port set.

Deploying the factory itself

scripts/factory.sh points your local docker CLI at the host over SSH (DOCKER_HOST=ssh://…), so up/redeploy build the image on the host — no registry. redeploy is build → tag current as :prev → swap → health-check → auto-rollback. In practice you don't run it: pushes to main are redeployed by a self-hosted GitHub Actions runner that is itself a container on the docker host (docker-out-of-docker), so no deploy credential ever lives in an agent-operated container. See self-deploy.md.


2. Containers: one per issue, from a pool

The diagram above shows one container per project. That is no longer the unit of work: since AIF-139 each issue gets its own container, drawn from a capped, factory-wide pool, so two issues never share a checkout. A project's own container still exists — it is the pool's first member.

src/pool.ts is the side-effecting lifecycle, src/poolplan.ts the pure allocation rules (no docker, no db, no clock — so they can be tested exhaustively), src/poolstore.ts the rows.

Allocation

allocateFor(project, issue) never blocks the beat: it returns what it has right now and provisions in the background. The rule, in order:

  1. an idle container of this project (and this backend) → reuse it;
  2. room under the cap (MAX_CONTAINERS, default 4) → create one;
  3. an idle container of another project → evict it, then create;
  4. otherwise → block until something frees up.

Plus wait: if one is already coming up for this project, sit tight rather than race a second one up. A starting row counts against the cap the instant it exists, which is what stops two beats both "finding room". Decide-and-reserve is one synchronous transaction; the slow work happens outside it.

"Mine" means this project and this backend — a project that ran on local and was later switched to fly has rows on both, and they are not interchangeable.

Tiering: the ordered policy (AIF-302)

The factory-wide default is not one backend but an ordered list of tiers, each with its own cap: [{local, cap 4}, {fly, cap 6}] reads "four local containers; when local can place nothing, spill to fly, up to six".

decidePlacement runs the rule above once per tier, in policy order, and takes the first tier that returns anything other than block. That composition is the whole design, and three properties fall out of it rather than being coded:

  • wait beats spilling. One already coming up on tier 1 is not block, so nothing races a duplicate onto a tier that costs money.
  • Evicting beats spilling. Within a tier the order is still reuse → create → evict → block, so tier 2 is reached only when tier 1 is at its cap and has nothing idle. Evicting costs a cold start for work that already finished; spilling costs money and a cold start.
  • The pool drains back on its own. Reuse is per tier, so as tier 1 frees up new work lands there and the spilled containers go idle and are reaped. No rebalancer.

Each tier sees only its own backend's rows, which is what makes the caps per-backend without countsAgainstCap learning about backends. It also scopes eviction, which is a deliberate change from the untiered rule: under per-backend caps, evicting a fly container frees nothing on local. With one tier every filter is the identity, so an unconfigured factory is unchanged.

A tier is only eligible if it can actually take the work. Tier 1 needs only to be reachable; a spill tier must also have been available on the last two samples (so a flapping backend cannot take a spill it will strand) and have a published image for this project (spilling onto a backend with no image is a multi-minute build at peak, looking like a hang — AIF-260 one level up). Health comes from backendhealth.ts, sampled once per beat and read synchronously inside the placement transaction; a sample older than four beats reads as unavailable rather than as its last value. Health never stops the factory: if no tier is eligible, the first one refused for a merely momentary reason is tried anyway, which is what the factory did before tiering existed.

A project override still names one backend and pins the project to it, with no spill — AIF-259's principle, that an operator picks a backend for reasons and a project that must not move needs a way to say so. The ordered policy is a factory-wide default only.

Spilling is said out loud: leaving tier 1 puts a comment on the issue naming the tier and why, because tiering deliberately reintroduces "ran somewhere you did not individually pick" and the bar is that the issue itself answers it. The Containers screen badges each container's tier (derived from its backend against the current policy, so a reorder relabels rather than going stale) and the Health page reports running/idle/cap per tier. Each tier also carries its own reap timeout, defaulting to the global one — a spill tier wants a much shorter fuse, since it absorbs bursts rather than holding warm checkouts.

Addressing: the container, not the project

execSSH & co. take an SshTarget. Get one from resolveTarget(project, issue) (or resolveSessionTarget), which returns the held container's target if the project is pooled and the project's own host otherwise. Passing project directly still compiles — it means "the project's own host", which is only right for an unpooled project.

hasDedicatedContainer(issue) is the related question: if the issue owns its container, its stages check the branch out in the container's main checkout (nobody else is there to clash with). If not, they fork a worktree, as before.

Synthesis, not adoption

Every container is synthesized from the project's repo param and its image (runArgsFor in backends/local.ts): its own name, which is also its network alias and the stem of the two named volumes it owns (<name>-data, <name>-workspaces). The project's first container and its twentieth are made by the same call, and none of them is special — nothing is pinned, nothing is exempt from eviction, and losing one costs nothing that cannot be rebuilt.

It used to work the other way (removed in AIF-292). A human ran scripts/project.sh; the pool adopted the container that produced as the project's first pool member, pinned so it was never evicted; and its docker inspect became the template every sibling was cut from. That made the first container a single point of failure and meant the server could not describe a container it had not already been shown one of — so a project could not exist until somebody had run a shell script against the docker host. The clone URL is enough to build the image and the params are enough to run it, which is what the fly backend had always done and what the local one now does too.

Release, and the sweeps that catch what release misses

A container goes back to the pool through persistAndRelease, which pushes every branch the issue produced first and only then lets go. That ordering is the protection: the container stays held — and so can never be evicted — until the push has actually landed. A push that keeps being refused escalates to a rescue ref after five consecutive failures, so the work can get off the box rather than pinning it forever.

That escalation assumes the push can eventually land somewhere. When it cannot — a root_dir that is not a checkout, so factory-persist itself cannot run and --rescue is refused identically — the retry is bounded: after roughly an hour of consecutive failures the factory says so on the issue thread and releases the container anyway (AIF-475). A local diff that provably cannot be pushed is worth less than a pool slot, and the pool is factory-wide: one misconfigured project once held all 55 slots for two and a half days. The same bound covers the helper-refresh deferral, so a refresh that can never land cannot hold a slot by never attempting the push at all. Project root_dir must now be an absolute path, which is what stops the commonest cause at the door.

Two paths release normally: a transition into a terminal status, and a transition into cancelled (which cleanup.ts owns — pushing there would resurrect work the operator threw away). Every other way an issue can stop moving leaks the container: dragged back to backlog, failed into error, parked waiting on a human. sweepAbandonedHolds is the rule that takes it back on a timeout (CONTAINER_HOLD_TIMEOUT_SEC, default 1800), through the same persistAndRelease. It is deliberately conservative — a run still going, a stage serving from that checkout, a conversation work turn in flight, or an unreadable clock all mean "don't touch it".

The window is per hold, not one factory-wide number (AIF-343). A container an issue conversation asked for, that no pipeline run has since used, goes back after TERMINAL_CONTAINER_HOLD_SEC (default 600) instead — nobody but the operator ever wanted it. Once a run starts against it the ordinary rule applies again, and CONTAINER_HOLD_TIMEOUT_SEC=0 still turns the whole sweep off.

That timeout is why dev-agent branches a missing $BRANCH off origin/$BRANCH rather than the base branch: a later stage can land on a container that has never seen the branch, and cutting from the base would orphan the work.

The remaining sweeps cover the cases that fall between: sweepVanishedContainers (a row whose instance was destroyed underneath it), sweepIdleBilledContainers (idle, healthy row, on a backend that charges for it), and sweepOrphans (an instance with no row — every teardown drops the row before calling the backend, so a refused destroy leaves one running that nothing counts).

Compute backends

Since AIF-227/AIF-229 the pool does not talk to docker at all; it goes through a ComputeBackend. src/backends/ is a registration table (local, fly) — the same shape messaging.ts uses for its providers, and for the same reason: adding one should be an entry plus a module, not another conditional threaded through every call site.

Resolution happens in exactly two shapes, and keeping them apart is what stops a settings change breaking containers that already exist:

tiersFor(project)         project override (one tier) > the ordered policy
backendFor(project)       the first of those tiers that can actually run
backendForContainer(row)  whatever CREATED that row              > local
  • backendFor(project) chooses one. Called only from reserve() — the one place that makes a row from nothing. A choice only counts if it names a backend that is both registered and configured(); local is always both, so the chain always terminates.
  • backendForContainer(row) recalls the one that made an existing container, off the row itself. Everything else — target, destroy, helper refresh, persist — goes through this, so it keeps addressing the box it actually made no matter what the project's setting says now. It deliberately checks registration but not configured() — revoking a token must not strand the machines that backend already owns.

Selection is a factory-wide ordered policy (config key compute_backend_policy, the Backends tab on factory settings — see Tiering above) plus a per-project override (the Backend select on a project's General tab). The older single-value key compute_backend is still read when no policy is stored, so a factory that had chosen one backend keeps running on it. The credential itself is env, never the UI — FLY_API_TOKEN, alongside LINEAR_API_KEY and the Twilio secrets — the settings page only reports whether a secret is present.

An unusable selection is a hard stop, not a fallback (AIF-259.) This chain used to degrade to local at every level. That was wrong: a project set to fly quietly ran every issue on local docker, so the work "succeeded" in a venue nobody chose while the fly dashboard stayed empty. backendFor now returns null and the pool refuses to place work, which the engine notes on the issue rather than skipping it silently every beat. backendForContainer still falls back, deliberately — it addresses a box that already exists, and revoking a token must not strand every container that backend owns.

Merge queues: how a pull request lands

A second registration table, deliberately the same shape as the compute one above (AIF-307). It answers a different question — compute backends decide where an issue's container runs, merge queues decide how its pull request lands — and the two are chosen independently: a project on fly can still land through Mergify. src/mergequeue/ holds the table; the merge stage hands the PR off and stops, and the chosen queue is polled on the beat.

kindwhat it doesserializes?
githubWatches the PR's checks and merges it itself when they are green.no — the merge stage's max_per_project: 1 is what bounds it
mergifyHands the PR to Mergify (@mergifyio queue) and watches what Mergify does with it, through GitHub-visible signals only.yes — Mergify admits and orders the merges

Both are configured() on GH_TOKEN alone; neither needs a second credential. ownsSerialization is what a queue declares about itself, so nothing above the seam has to test for a backend by name.

Selection mirrors the compute chain — a per-project override (the Merge queue select on a project's General tab, or PATCH /api/projects/:id {"merge_queue":"mergify"}) over a factory-wide default (config key merge_queue_backend, the Backends tab on factory settings). A backend's declared params are rendered under whichever picker selects it and are stored in the project's ordinary params map — mergify_queue is the only one today.

An unusable selection is a hard stop here too, and for a sharper reason than AIF-259's: a project pointed at Mergify whose PRs were quietly landed by the factory's own merger has had its queue discipline silently removed. So a selection must name a queue that is both registered and configured, or nothing lands and the wait fails with the reason on the issue.

The one asymmetry worth knowing: when nothing is chosen at either level the chain terminates on github ungated — no configured() check. A factory with no GH_TOKEN therefore keeps its pre-AIF-307 behaviour (the poll throws, the wait retries next beat) instead of failing the issue outright. Choosing github explicitly is validated and can be refused; inheriting it cannot. This is why the settings select sends "" rather than "github" for its default option — the two are stored differently and resolve differently.

docs/merge-queues.md covers the backends themselves, including what a repo must have for Mergify to work and what happens when it does not.

Agent backends: which coding agent does the work

A third registration table of the same shape (src/agents/, AIF-392), answering the third question: compute backends decide where a container runs, merge queues how its PR lands, agents who writes the code — every pipeline stage and every conversation turn.

kindwhat it isneeds
claudeClaude Code, exactly as the factory has always run it.usually nothing — the container's own login authenticates it
kimiKimi (Moonshot), via its Anthropic-compatible endpoint.KIMI_API_KEY
openaiOpenAI, via an Anthropic-compatible gateway the operator runs.OPENAI_BASE_URL and OPENAI_API_KEY
openrouterOpenRouter, which fronts many providers and serves the Messages API itself.OPENROUTER_API_KEY

The last three are one implementation: a passthrough that points the claude CLI at another provider with ANTHROPIC_BASE_URL + ANTHROPIC_AUTH_TOKEN. Because that is an env change at the server, no tool or workflow YAML changes when the agent does — but a workflow's model: alias does, so runner.ts rewrites PARAM_MODEL through the selected backend on the way to the run.

Unlike the two tables above there is no per-project override: one factory-wide choice (config key agent_backend, the Agent section of the Backends tab), resolved once per run so the credential a run authenticates with and the model it is told to use provably come from the same backend. An unusable selection is a hard stop here too — the run fails with the reason on the issue rather than quietly spending someone else's budget on Claude.

The github asymmetry repeats exactly: nothing stored resolves to claude ungated, because a factory whose credential is the container's own /data/claude login has no token in its env at all and gating the terminator would take every one of those from working to running nothing. Choosing claude by name is validated and can be refused.

A backend's non-secret settings are stored, not env-only (AIF-441): its base URL and the model it runs for each of the three aliases are per-backend rows in the config table (agent_settings:<kind>), edited from the same Backends tab and resolved stored > env var > the registration's default. API keys are the exception and stay env-only, so nothing on that page can carry a credential — which is also why the settings write is not gated on the backend being usable: for the gateway backend the base URL is what makes it usable.

docs/agent-backends.md covers the backends themselves — the credential per backend, the model-alias table, the settings, and how to point a factory at Kimi or OpenAI end to end.

The fly backend, and its bootstrap

A fly machine is not a docker container on a host the factory owns, so several things differ:

  • Image (flyimage.ts). A machine boots from registry.fly.io/<app>:latest and nothing else creates that app or that image, so a project on the fly backend is dead until one publish has happened. The rebuild loop (projectrebuild.ts) does it: with no machine and nothing in the registry (backend.imagePublished) it builds and publishes, then stops — there is no instance to swap onto. Do not re-gate that path on the project already having an instance; that was the deadlock (AIF-260). One app per project (FLY_APP_TEMPLATE, default conveyor-{project}-project).
  • Warm state (warmimage.ts). A cold machine would clone the repo and install dependencies over the public internet on every issue, so the repo and its dependency caches are baked as a layer on top, only on the fly publish path — a fly Volume can't do this, since it attaches to one machine at a time and isolation here is the machine boundary. Best-effort: a failed warm build still publishes the plain image, and the machine just starts cold.
  • Machine (fly.ts). One machine per issue, cut from the image's floating :latest, tagged factory_pool / factory_project so the orphan sweep can tell its own machines from anything else in the org. pooled() is unconditionally true: nothing pre-exists on fly to take over, and since AIF-292 there is nothing to take over on the local backend either.
  • Reachability (scripts/fly-wg.sh). target() returns the machine's 6PN (fly private network) address. The factory container joins the org's private network as a WireGuard peer at boot, from container-init.sh, as root and best-effort — which buys arbitrary TCP rather than just SSH, so staging (§3) needs no fly-specific code: the stage proxy opens its plain HTTP hop to the same 6PN address. If the tunnel is down, switching a project to fly still "succeeds" and its machines simply cannot be reached; fly-wg.sh --status says which world you're in, fly-check.sh proves the token opens the org's Machines API.

Fly also bills for idle machines, which is why sweepIdleBilledContainers exists (CONTAINER_FLY_IDLE_REAP_SEC, default 300). On docker an idle container costs nothing and is a warm box for the next issue, so it is only reclaimed lazily, when the pool is at the cap and needs the slot. See ../CLAUDE.md for the operational runbook.

There is no fallback — a project that cannot be built is refused (AIF-611)

A project used to be able to opt out of the pool — no repo to build from, or CONTAINER_POOL=0 — and keep running on one long-lived box named by projects.ssh_host. That column is gone, along with the flag, the legacy allocation result and requiresProjectHost.

What replaced the fallback is an honest refusal. allocate() answers unusable with the reason when the backend will not pool the project (on local that means it carries no repo, so there is nothing to build a container FROM), and the beat comments that reason on the issue once. resolveTarget and resolveSessionTarget return null rather than standing in the project, so every caller has to say what it does with "no container yet" — wait, degrade, or refuse. That is the point: the old fallback compiled only because Project structurally satisfies SshTarget, and what it produced at run time was an SSH to '' that read as the tool crashing.


3. Standing up and connecting a project

Three actors, and it matters which does what.

From the dashboard — add a project with its clone URL

There is no provisioning step for a human to run (AIF-292; scripts/project.sh was deleted with it). A project needs a name and a repo param, and the factory does the rest the first time an issue asks for a container: create() builds the image if this daemon does not have it, then runs a container from it.

The image is synthesized from the factory's project/Dockerfile with the repo's .factory/development/Dockerfile fragment appended, project/features.json merged with the repo's .factory/development/features.json (backends/imagebuild.ts). Everything the factory reads to build the development container lives under the repo's .factory/development/ — clearly marked as a factory interface. A repo's own deploy/ is its live-deployment setup and is never read here.

Devcontainer is used only as a feature-aware image builder — no devcontainer runtime and no workspace mounts. When the merged feature set is non-empty the image is built by the devcontainer CLI (backends/devfeatures.ts, pinned and invoked through npx) over the same context; when it is empty the build is a plain docker build and no Node download happens. A declared feature that cannot be installed fails the build — there is deliberately no featureless fallback, because a silently featureless image is what AIF-215 was.

The built image's feature containerEnv is folded into the container (base64 via FEATURE_ENV_B64, read by project/container-init.sh into /etc/environment) so SSH sessions see it. That is not redundant with the build: the CLI bakes ENV only for legacy v1 features, so a v2 feature — every ghcr.io/devcontainers/* one — records its containerEnv only in the image's devcontainer.metadata label, for a runtime we do not run to apply. Both backends read that label off the image and pass the result as container env.

Cross-building (fly) goes through the same CLI with --platform. It shells out to docker buildx build and never names a builder, so BUILDX_BUILDER in its environment is what selects the aif-cross one — without it the build lands on the default builder, which accepts --platform, ignores it, and returns a host-architecture image (see §the cross-build note in imagebuild.ts).

Facts about the repo the factory needs before any container exists — the default branch, its HEAD, and the dev-config hash that decides whether an image has drifted — are read straight from the git remote (repodeploy.ts, one git ls-remote plus a bare depth-1 fetch). They used to be read over SSH from the project's own container, which is why a project needed one before it could have one.

No project shape needs a box of its own any more: a project with no repo is refused rather than run on one — see There is no fallback above.

Inside the project container — project/container-init.sh

On boot, as root: write env to /etc/environment (PAM loads it for SSH login sessions, which is how the factory's non-interactive shells see it), install the factory's public key and the operator's key into dev's authorized_keys, persist sshd host keys, configure git identity + gh auth setup-git, then clone the repo from its URL into the ~/workspaces volume — a clone, not a bind mount, so the container is self-contained — bootstrap an initial commit if the repo is empty, run the repo's .factory/development/setup.sh (or a default lockfile install), and finally exec sshd -D.

From the web UI — Connect

Registering the project in the dashboard and pressing Connect runs src/setup.ts over SSH. That is a software install into an already-existing container; it provisions nothing. It creates the data-folder tree, scps the helper scripts into <root>/<data>/runtime/bin/, and runs each pipeline tool's setup_script (hash-tracked, so it only re-runs when the script changes). A run is never launched before its tool's setup has succeeded.

Connect is rarely needed by hand — the engine's ensureProvisioned does the same thing automatically whenever the helper set or a tool's setup script changes.

Launching a run

src/runner.ts writes an ISSUE.md snapshot and the run dir over SSH, then launches run-wrapper detached with a composed environment:

VarsSource
FACTORY_URL, FACTORY_RUN_ID, FACTORY_RUN_TOKENcallback base + the project's durable bearer token
FACTORY_ISSUE_ID/KEY/TYPE, FACTORY_ISSUE_DIR, FACTORY_DOCS_DIR, FACTORY_RUN_DIRthis issue and run
FACTORY_EXEC_FILEpath of exec.sh in the run dir, holding the tool's exec command (a file, not an env string: one env string is capped at 128 KiB — AIF-648)
PROJECT_*every project param
PARAM_*tool defaults overridden by step params
PROPERTY_*issue properties

Param values are passed as env only — never interpolated into the remote shell command, so a value cannot break out.

run-wrapper runs bash "$FACTORY_EXEC_FILE", tails the combined log to the factory every ~2s, heartbeats when idle, and reports the exit code with retries. It also drops a .factory-run marker in the run dir so the helper CLIs still work from a fresh shell that lost the environment.

Staging (the Run button)

The factory has no docker daemon of its own for project work, so a stage runs inside the project container: factory-stage up locates the branch's worktree and runs the repo's own executable stage script, whose contract is just "start the app and print PORT=<n>". The factory then proxies one of its own stage ports (8080+) to it, giving the staged app a whole origin rather than a sub-path. Up to STAGE_MAX_CONCURRENT at once. See staging.md.


4. What lives where — repo vs. container

Written by the developer, in the project repo (all optional)

The factory's development-container inputs live under .factory/development/ — a clearly factory-owned interface. A repo's own deploy/ (live deployment) is a separate concern the factory never reads.

FileEffect
.factory/development/envvariable overrides; also passed to setup.sh as env
.factory/development/features.jsondevcontainer features (strict JSON), merged over the factory default
.factory/development/Dockerfileextra build steps appended to the default image (a fragment — no FROM)
.factory/development/setup.shdev setup run after clone (else a default lockfile install)
.factory/development/compose.override.yamlports, mounts, sidecars (service dev)
stageexecutable; starts the app and prints PORT=<n> — enables the Run button
CLAUDE.mdinstructions the agent reads every run

Samples live in project/examples/.

Deployed by the factory into the container, never committed

Installed into <root_dir>/<data_folder>/runtime/bin/ (default .factory/runtime/bin/) and put on the tool's PATH:

run-wrapper, factory-progress, factory-ask, factory-set-status, factory-comment, factory-summary, factory-issue, factory-history, factory-ready, factory-wait, factory-stage, factory-usage, factory-cleanup, factory-persist, factory-checkpoint, factory-pr, factory-reintegrate, factory-git-auth.

The list itself lives in HELPERS (src/setup.ts); that array is what ensureProvisioned hashes, so adding one is enough to push it to every project.

factory-persist and factory-checkpoint are two halves of one guarantee: work must not live on only one container. Persist pushes every committed branch and deliberately invents no commit; checkpoint makes the wip: commit that gives it something to push (AIF-263). The dev-agent checkpoints after every failed attempt at a stage, and the pool checkpoints immediately before persisting a container it is about to release — so an uncommitted edit is saved at the last moment it can be, rather than reported as PERSIST_DIRTY and lost with the box. The commits sit on the issue's own branch and are squashed away when its PR merges.

factory-pr land <repo-root> <branch> <base> is what the Merge stage uses to put a branch on the base branch: it pushes, opens (or reuses) the PR, waits for its checks and squash-merges when they are green (AIF-197). It speaks the GitHub REST API with curl + $GH_TOKEN rather than gh, which arrives as a devcontainer feature and so is absent from any container built before AIF-215 — and from a legacy project the factory does not build at all. Exit 3 means the origin is not on GitHub, and the stage falls back to a local merge + push.

These are baked into the factory image and re-scp'd whenever they change.

Baked into the project image

Node, Claude Code, TypeScript, curl, and gh (from the default devcontainer feature); sshd running as user dev. Tool setup scripts install anything else a specific tool needs. The wrapper additionally requires bash, curl, coreutils, ps and setsid.

Persistent volumes on the project container

The repo checkout at /home/dev/workspaces/<name> and the one-time Claude login at /data/claude are per-container named volumes, so an image rebuild swaps the container without losing the workspace or the login. Per-container is exact: the volumes are named after the container that owns them (volumesFor), so each issue's container gets its own checkout and teardown removes precisely those.


5. Committed vs. runtime-only

This is the rule the on-disk layout exists to enforce: the factory writes a lot into the project checkout, and almost none of it is tracked. The split is a single directory boundary under the project's data_folder (default .factory/).

<root_dir>/
  .factory/
    .gitignore                       ← contains exactly: runtime/
    issues/<issue-id>/documents/     ← TRACKED. stage docs, committed onto the branch
    documents/                       ← TRACKED (opt-in). central project docs
    runtime/                         ← IGNORED WHOLESALE. everything below here
      bin/                             the helper scripts
      issues/<issue-id>/
        ISSUE.md                       the snapshot a tool reads
        .claude-session                session id for stage-to-stage resume
        documents/                     the working docs a stage writes
        runs/<run-id>/                 output.log, prompt.txt, agent.out, usage.txt, .factory-run

src/paths.ts is where this lives. Note the deliberate asymmetry: the tracked docs dir (.factory/issues/<id>/documents) is a sibling of runtime/, which is what lets one runtime/ ignore line cover every runtime artifact without also ignoring the documents.

What the factory commits

On a successful non-merge stage that has a worktree, the dev-agent tool:

  1. rewrites .factory/.gitignore to runtime/ (in place, so existing projects migrate);
  2. copies $FACTORY_DOCS_DIR/*.md → .factory/issues/<id>/documents/;
  3. git add -A and commits as AI Factory <factory@users.noreply.github.com> with message <phase>: <slug>;
  4. git push -u origin <branch> and comments on the issue whether it worked.

So a branch carries the real code changes plus that issue's stage documents (PRD.md, ARCHITECTURE.md, JDI.md, REVIEW.md, …), and nothing else the factory generated.

Optionally — per project, via manage_project_docs — the review stage also reconciles central docs at .factory/documents/ (features.md, design.md, api.md, architecture.md). Only when accepting and advancing, never when pushing back. These describe the project as it is now; they are not a changelog. FACTORY_PROJECT_DOCS_DIR is passed relative to the repo root on purpose — the agent's CWD is the worktree, and an absolute path would land edits in the main checkout, off-branch.

Each of those four documents is held to a line budget (doc_budget_lines, 600 by default; a project raises it with a project param of the same name) and opens with a one-line-per-section map, and the reviewer is told each file's current size and to compact rather than append past it. The whole clause is skipped when the branch's diff touches only tests, docs//top-level markdown, or .factory/ — an objective path check in dev-agent, not something the agent is asked to judge. (big_task reconciles too, since it contains its own review.)

What is never committed

Everything under runtime/: the ISSUE.md snapshot, run logs and prompts, per-run scratch, .factory-run markers, the installed helper bin/, stage bookkeeping, and the .claude-session id (machine-local, and outside documents/ specifically so the doc-copy can't sweep it into a commit). The result is that a project's checkout never accrues dirty state from the factory operating on it.

Branch and worktree lifecycle

Each issue works on its own branch, issue/<key>-<slug>, forked from PROJECT_BASE_BRANCH (default main). Where that branch is checked out depends on whether the issue owns its container (AIF-149):

  • it does (the normal case since AIF-139) — the branch is checked out in the container's main checkout. Nothing else runs in this container, so there is nobody to clash with, and the checkout a human SSHes into is the one the agents actually used;
  • it doesn't (an unpooled project, whose issues share one container) — the stage forks an isolated worktree at <parent>/.factory-worktrees/<repo>/<key>, or two concurrent stages would fight over one working tree.

An issue that already has a worktree keeps it even on a dedicated container: it may hold uncommitted work, and moving an in-flight issue would strand it.

Beyond that:

  • the checkout is created idempotently by the first stage that needs it and reused by every later stage of that issue;
  • a branch that exists only on origin is fetched first, and a missing $BRANCH is cut from origin/$BRANCH in preference to the base — a stage can resume on a container that has never seen the branch (see the hold sweep above), and cutting from the base would silently abandon the work;
  • a stage with workspace: none (e.g. the plan phase) skips all of this and runs read-only in the main checkout — nothing is committed or pushed, though its documents still land in FACTORY_DOCS_DIR for the dashboard;
  • the merge phase runs from the repo root with explicit git -C, because it may remove the worktree — cd-ing in first would delete its own CWD. It only removes one when the worktree and the repo root are genuinely different directories;
  • cancelling an issue cleans up its branch and worktree (src/cleanup.ts), and releases its container.

6. Epics, and landing one as a branch

An epic is an issue of type epic — same issues row, key, thread, documents, properties — not a table of its own (AIF-487). Members point at it with epic_id; an epic cannot itself belong to another epic. A workflow that declares no epic type has no epics, which is the signal the dashboard uses to hide the tab. Running an epic means the scheduler promotes members, in the epic's manual order, to their type's hand-off status, keeping EPIC_ISSUE_PROCESSING_LIMIT in flight — but it no longer decides an epic is finished: once every member is terminal the epic sits on in_progress and waits for a human's Approve.

epic_branch is an issue property (types: [epic]), off by default:

epic_branchMembers fork from / land onApprove closes to
offthe project's own base branchdone, directly (the beat, no pipeline step)
onepic/<key>-<slug>testing -> updating -> merging -> done
  • The branch name, epic/<key>-<slug> (epicBranchName() in epics.ts), is a contract with ci.yml's pull_request filter (below) — both have to agree on the pattern.
  • The server computes it once per issue and hands it to dev-agent as FACTORY_BASE_BRANCH (the same var the project's own base branch already used), which is what makes the fork point, update-to-latest, the merge step and factory-pr's PR base all move together. Flipping the box only affects work that hasn't landed yet.
  • The branch is cut by the epic's first member that needs it, not the server (which has no checkout) — forked off the project's base branch and pushed. A push origin refuses falls back to the project's base branch rather than working against a base GitHub would later refuse a PR into. That fallback is reported on that member's own thread, not the epic's — factory-comment posts to the issue whose run is speaking — so an epic whose members quietly landed on the base branch shows nothing on its own thread to say why.
  • The epic issue's own work IS that branch: it reaches dev-agent/test-runner as a second var, FACTORY_WORK_BRANCH ("" for everything else). dev-agent checks it out and never creates it — since a member cuts it first, a missing branch here means the usual fall-through would cut a fresh one off base and open a PR that reverts every member's work, so it stops instead.
  • The fork on Approve lives in the beat (epicrun.ts's settleApprovedEpics), not a pipeline step: a step is keyed by (type, status) alone, so one on approved would fire for a branchless epic too and race the close. testing tests the whole epic branch as ONE tree — something no member's own test run does; updating folds in a moved base and opens the PR; merging waits on the queue. It shares queue: merge with ordinary member merges (AIF-250) — one per-project slot for both.
  • Two knobs in that pipeline look like oversights and are not. The Testing step sets auto_approve_status: "" explicitly, because it defaults to approved — and on an autopilot epic a green run would jump there, straight back into the beat's fork, which would send it to testing again forever. A human already approved this epic; there is no second gate to skip. And the epic type's error status — where the landing flow parks an epic it cannot proceed with at all, no branch on origin or no test script — is declared after done on purpose. runningEpicStatus() derives "the status that means the factory is working this epic" as the FIRST active-category status by sort, and does not skip parked ones; error shares in_progress's implementation category, so declaring it earlier would make it that status, pointing the member scheduler and the dashboard's Start button at error instead of in_progress.

A project turning epic_branch on needs its own CI to match. ci.yml's pull_request trigger matches the PR's base branch, so a member's PR into epic/<key>-<slug> is checked only if that filter includes epic/** (this repo's does: branches: [main, "epic/**"]). Skip it on another project and such a PR gets no checks at all — nothing red, because nothing ran — so green-gate never appears, Mergify's check-success=green-gate is never satisfied, and the issue sits on merging forever with nothing to point at.

7. Specialists: a standing agent that files its own epics

A specialist is a long-running domain expert attached to a project — one quality (performance, accessibility, architecture, test health, security, docs…) watched continuously. On a cadence it evaluates the codebase, records what it believes is wrong, files issues for the fixes, and lets the ordinary pipelines implement them. You set it up once, then check in to see what it did.

It is its own entity — a specialists row, its own page and nav item, its own key from the same per-project sequence issues and epics draw from, its own comment thread — that creates and manages epics as the visible unit of its work. Every epic it opens is completely ordinary: normal members, normal branch (§6), normal Approve, normal summary, normal close to done. Nothing about epics changes. Everything specific to standing work — charter, budget, autonomy, WIP, cadence, the findings ledger, the report — lives on the specialist, which is what keeps it out of the epic.

It is also deliberately not an issue row of some type: specialist. A specialist has no workflow status machine, no pipeline and no place on a board, so being an issue would mean hiding it from every issue surface — and each of those exclusions is a future bug.

The design that was dropped. The specialist used to be a long-lived epic that never closes, with a custom evaluating/paused/over_budget track bolted onto the epic type. Because one epic branch had to survive being squash-merged into main over and over, it needed a freeze → re-cut → replay → force-push dance to stay alive. Making the specialist its own entity removed the need for all of it: it opens a fresh ordinary epic, works it, lands it through the unmodified Approve flow, and opens the next one. There is no git mechanic here at all.

Domains

A domain is the area of the product an issue is work on — a per-project tag on a real column (issues.domain_id, domains table), not a workflow property, so it filters and indexes everywhere. null is ordinary human work. The slug is the handle and is never silently suffixed (a collision is a 409); none is reserved as the filter word for "no domain at all". An unknown domain narrows to an empty list on a filter but is a 400 on a write — a dropped tag on a create is silent data loss where an empty list is visibly empty.

Inheritance is resolved server-side, and the two callers are not symmetric:

CallerRule
an agent on a run token (issueapi.ts)a domain in the request is ignored. The answer is the domain of the issue this run is for, else that issue's epic's, else the epic the new issue is filed into.
a human (dashboard, public API)an explicit choice always wins — including a deliberate none. Inheritance only fills a blank.

Caller lineage first is what makes the agent rule "you cannot escape your domain" rather than "you pick a domain by choosing an epic". A domain nobody chose is explained on the new issue's thread, folded into the creation note.

Epic ownership inheritance is the same argument one level up: an agent working inside a specialist's epic — on the epic or on a member — files its follow-ups into that same epic, because a follow-up that escapes it escapes the WIP cap and the budget with it. Scoped to specialist-owned epics only (a plain epic keeps today's loose behaviour), and unlike the domain an explicit --epic wins rather than being ignored, since it only fills a gap.

Epic ownership and succession

The link is issues.specialist_id — issues, not epics: an epic is an issue (§6), and the legacy epics table has been unread since schema v43. It is set only on epic rows. specialists.active_epic_id is the other half of the same fact, and setActiveEpic() writes both in one transaction, because splitting that write across callers is how halves drift.

A specialist has at most one active epic — the one new findings are filed into. Clearing the pointer drops the specialist's side only: the epic keeps its specialist_id, which is a fact about who created it that stays true after the specialist moves on, and is what lets a landed epic still be attributed.

Succession is one decision (decideSuccession) and runs on the beat: the landed epic is cleared on the beat that notices. The beat never opens the replacement (AIF-642) — the first member a cycle files does (openEpicForFiling, inside the create route, after every check has passed), so a cycle that finds nothing leaves no empty epic behind. A halted specialist does nothing at all — not even the bookkeeping clear, because that would discard the one fact a returning operator needs. The new epic is named <name> — pass <n>, carries the specialist's domain, and gets epic_branch from the specialist's landing. Landing itself is the unmodified Approve flow; the epic-cadence policy can only ever delay that moment, never bring it forward.

The evaluation cycle

A cycle is a standalone run attached to no issue. It needs the whole run apparatus — detached launch, heartbeats, output capture, the factory-* helpers, usage and cost — but must be invisible as an issue, so it gets an ordinary runs row whose issue_id holds the specialist's own uuid. That is precedent, not a trick: the thread is keyed to comments the same way. What falls out of it is the good part — invisibility is structural, because every issue-scoped query either filters issue_id=? or INNER JOINs issues, so a cycle run holds no step slot and cannot surface as an issue; and the helpers work unchanged, since factory-comment posts to addComment(run.issue_id, …) which is the specialist's thread. step_id is deliberately empty so a clean exit does not try to bump the status version of an issue that does not exist.

The due check reports the first gate that applies, in the order an operator would ask them: disabled → in_cycle → halted → wip → interval → quiet → unchanged. Run now records a durable request and the beat starts it (two launch paths is how a button and a due check come to disagree), and it bypasses only the last three — budget, pause, retirement and the WIP cap are all somebody having said stop. Probes run server-side before the agent, because their output is one of the prompt's inputs.

The findings ledger is keyed (specialist, fingerprint) — not to the epic it was found in, which is the whole reason a ledger exists instead of just issue bodies: a fingerprint has to survive an epic landing and the next one opening. Evidence is required (an opinion recorded as a finding is what makes a ledger unreadable after a month), and a rejected fingerprint needs new_evidence to come back, or a specialist would argue with its operator forever. Decisions — and only decisions — append to specialist_finding_events, so a reject → re-open → reject keeps both reasons.

A cycle that found nothing doubles the interval (consecutive_noops), capped at a week. The cap is on the result, not the multiplier, so an operator who configured a longer interval is never stretched past it by the backoff.

Governance: autonomy, WIP, budget

status is where the machine is; enabled is what the operator asked for. They are separate so a specialist parked over_budget still remembers it is wanted, and comes back on its own. active and evaluating are going; everything else (paused, over_budget, error, retired) is halted, and halted means no promotion, no landing, no succession.

AxisEffect
autonomypropose files members for a human to release and needs a human to land; auto lets the scheduler promote and land. Halted beats both — but propose is not a halt, so a human releasing a member by hand still works.
max_running_membersHow many members may execute at once. Counted in runs, not statuses — a human bulk-releasing ten members would otherwise read as ten against a cap of one and deadlock every one of them.
max_open_membersHow much may be open in the active epic. A read the cycle asks before filing, not a gate: a human may hand-file past it.
background prioritySpecialist work sorts behind every issue a human is waiting on. Stable, and nothing is filtered — with the pool free everything still launches this beat, just in a different order.

The budget ledger spans everything the specialist has ever cost: its own cycle runs and every epic it has created, current and landed. That falls out of one join (COALESCE(i.epic_id, i.id) resolves a member to its epic and an epic to itself, plus a second arm for the issue-less cycle run) rather than needing a history table. Periods are calendar-aligned in UTC — a trailing window has no boundary, and both "warn once per period" and "back to active when the window rolls" need one. 0 means no cap.

Enforcement is at stage boundaries, so a single run can overshoot the cap by one run. That is a real property, not a rounding error: spend is only known once a run reports its usage, so a run that started under the cap can end over it. The UI says so in its footnote and shows the overshoot unclamped — "125% of $50.00" is the fact an operator needs; clamping to 100% would hide how far past it went. on_exhaust then decides what happens to work already moving: finish_in_flight (default) lets it finish, pause_all stops it at the next stage boundary. Either way a member that has never run is refused, because finish_in_flight is a promise about work already started, not a licence to start more — and the epic's own landing pipeline is exempt, since by then the money is spent and refusing to land would strand every member's work.

Separately, protected_paths is a fence on what it may change. The filing side tells the agent never to open work inside one; the scripted Testing stage checks what the branch actually changed, before the suite (a green run printed under a violation reads as approval of it) and parks the issue at blocked rather than bouncing it — this is not something an agent iterates on. The whole block is skipped when FACTORY_PROTECTED_PATHS is empty, which is every issue no specialist owns, and that guard is what makes it safe to put in a file every issue in the factory runs.

The report

The specialist's report rolls up what it has changed across every epic it has ever opened. Only history is stored: each landing freezes the epic's own write-up, each member's closing summary and the finding that justified it. The scorecard, what is in flight and what was turned down are computed on every read from rows the cycle already keeps — so a cycle refreshes them by doing its ordinary work, and a human rejecting a finding an hour later shows up at once rather than next cycle.

Every displayed string is frozen at landing and nothing on the read path joins back for text; ids are kept beside the frozen keys so links work while the rows live. Text frozen, links live — turning either back into a join would make a two-month-old report rewrite itself. No agent writes any of it: the only agent-authored words are the cycle's own summary line.

"Show me the delta since I last looked" is nothing more exotic than epics of mine that landed since your last visit — a per-user last_viewed_at mark, and never a filter on what is shown (the report reads the same for everyone; the mark drives the badge). An empty mark honestly reads as "all of it is new". ?since=last_integration narrows the history to the most recent landing.

Gotchas

  • issues.specialist_id, never epics.specialist_id. The legacy table is unread; writing there sets a field nothing reads.
  • A run may have no issue. Three places had to learn it — the cost ledger's attribution join, specialistOwningRun (so a cycle ending rechecks the budget), and reapIfDead (so a dead cycle is reaped instead of leaving the specialist evaluating for good). A fourth caller that assumes getIssue( run.issue_id) is non-null is the next bug of this shape.
  • In that ledger, e.type = 'epic' sits in the JOIN condition, not a WHERE. As a WHERE clause it throws the issue-less cycle arm away again — the one mistake the query's shape invites.
  • The one-specialist-per-domain index is PARTIAL (WHERE status <> 'retired'). It has to mean one live one, or retiring would burn the domain forever — retirement being the only exit the table offers.
  • Read status, not enabled, to decide whether it may act, and remember error is in the halted set (the issue's own summary of the track omits it).
  • The interval backoff caps the result, not the exponent. Clamping the exponent tightly silently becomes the real limit, and "capped at a week" quietly stops being true for every short interval — which is exactly the bug its test found.
  • Turning branch landing off is refused while the active epic still holds unlanded work. The epic's own epic_branch cannot move with it (members have already opened PRs against that branch), so the switch would leave the specialist claiming per-member landing while its epic accumulates onto a branch nobody will land. abandonActiveEpic is the way through, and it leaves the epic alone — not cancelled, not deleted — because a half-finished epic that still exists is something a human can finish by hand.
  • Retirement is terminal and refuses to strand work, unless asked to abandon.
  • A cycle's thread writes land on the specialist, not an issue, which is what lets an operator watch work appear on the page they are already on.

Dogfooding it on this repo

The eight built-in templates (config/specialist-templates/builtin.yaml, read from disk on demand — they only pre-fill the New-specialist dialog, so there is no row to keep in step) include Architecture and Accessibility, either of which is a good first specialist here. A sensible first setup:

  • Landing branch, so each pass accumulates on epic/<key>-<slug> and lands as one reviewable unit. This repo's ci.yml already matches epic/**, which §6 warns is the thing another project has to add.
  • Autonomy propose — set it explicitly, because branch landing defaults it to auto. You want to read the first few passes before the scheduler promotes and lands on its own.
  • A modest weekly budget (budget_period: week, a low period_budget_usd), which bounds the experiment whatever else is misconfigured. Remember it can overshoot by one run.
  • Leave the daily min_interval_min (1440) and only_when_changed alone, and keep max_open_members small.

A specialist is created paused and disabled whatever else you pass, so nothing runs until you resume it.


Where to read more

TopicDoc
The delivery pipeline, stage by stageworkflow.md
Status/workflow mechanicsstatuses-and-workflows.md
Running on fly.io§2 above, then the runbook in ../CLAUDE.md
Merge queuesmerge-queues.md
Agent backends (Kimi, OpenAI)agent-backends.md
Self-deploy / CI runnerself-deploy.md
Running a branchstaging.md
Ad hoc conversationsconversation.md
Linear synclinear-sync.md
Specialists (API surface)api.md
Agent git conventions../github/WORKFLOW.md
Quick start + operating../README.md