A kit a project copies into docs/devteam/ and owns. It runs a supervised fleet of coding-agent sessions that takes broad goals, turns them into a graph of tickets, and plows through it with superpowers, under one owner, with adversarial review before every merge and a loop that keeps improving the fleet itself.
Thirteen files: three governance docs, three process docs, one schema, one script, one dashboard and its two helpers for review pages, the hub page that lists every instance on the machine, and a gitleaks config for the kit's own log. Once adopted, agents are routed by a section init adds to the project's AGENTS.md; there is no second README.
docs/devteam/
governance/constitution.md who decides what, the trust ladder, principles, one-way doors, which rule wins
governance/quality.md definition of done, gates, the review standard, merging, releases
governance/code-style.md the code doctrine
process/planning.md goal → design → milestones → roadmap → graph
process/supervisor.md the tick as observe / orient / decide / act, questions, messages, the log, retros
process/worker.md one ticket with superpowers
schema/log.schema.json the eleven event types and who may write what
scripts/devteam.mjs the one script (node, no dependencies)
dashboard/index.html the owner's page
dashboard/devteam-ui.css the dashboard's look, for an agent's review page (served to it, nothing else)
dashboard/devteam-ui.js a review page's bridge to the dashboard, and a few components
dashboard/hub.html the machine's page listing every running instance (http://127.0.0.1:4320)
gitleaks.toml gitleaks' default rules, plus the allowlist the log's source_key needs (a project's .gitleaks.toml extends it)
project/ this project's content; upstream never touches it
settings.md only what this project changes
strategy.md why the project exists, who it serves, how it wins, its values and refusals; the owner's
designs/architecture.md the whole system now, updated by every plan PR
designs/<goal>.md one design per goal, the spine; a feature's own design when it's planned
log/<writer>.jsonl the durable log
tickets/<id>/ spec.md, plan.md, verification.md, reviews/ (written by superpowers), questions/ (its review pages and the owner's results)
artifacts/<slug>/ pages the fleet drew to show an idea (mocks, flows, models), listed in the dashboard's Library; on a cloud box the owner can publish a landed one to anyone
questions/<slug>/, questions/<qid>/ review pages the supervisor drew, and the owner's result.json for each, copied in by review result --commit; never changed once landed
reviews/<feature>/, reviews/<design>/ graph and design review records
retros/<date>.md
run/ gitignored: the live log, prompts, previews/ (a running dev server the owner can open)
Roles. The owner is the human: goals, one-way doors, production, the rules. The supervisor is the owner's foreground agent session in the terminal: it plans or makes planners, makes workers, rules, merges, and writes no production code (it may draw artifacts). A worker is one session for one ticket, in its own git worktree and tmux window, running superpowers. A planner turns one goal into a design, a feature and tickets on a plan/<feature> branch. A reviewer is any session or subagent that did not write the work; its default verdict is not approved.
Two loops. Goal → design → roadmap → graph → tickets: the supervisor interviews the owner until each goal is unambiguous, writes a design per goal (the spine that keeps many loops coherent), orders it into milestones, plans one milestone at a time into features with stories and releases, then into a graph of tickets, each one PR. Goals are meant to be large; the forecast says what one costs, and nothing splits it. Observe, orient, decide, act: every supervisor tick, what sessions report becomes observations; recurring observations become knowledge and problems; the supervisor decides what it may and asks the owner everything else through the questions inbox, saying why; a judgment call is a decision line that records what was weighed and the trust it rested on (its rung, the owner's answer, a default or a standing rule), and the acts that carry it out cite it; the dashboard's Decide view lists every call, the supervisor's and the owner's, and the Trust Ladder has its own page. The owner corrects a call, and a correction takes trust back and becomes a lesson. Retros gather the corrections and learnings across tickets and features.
What matters most. The supervisor judges each goal's impact against the project's strategy and rates every question it asks (how much rides on the answer, how soon, and which other questions an answer would settle). The kit counts the rest (tickets held up, a default) and orders the Inbox from it, with the next best step on top and the reason on every card; devteam.mjs next prints the same. The order is computed on every read and stored nowhere. The owner corrects a goal's impact with their own value, and it stands.
Many projects, one machine. Each project has its own tmux session (devteam-<project>-<6 hex of its path>, saved in run/tmux-session) and its own dashboard port: the next free one in 4321–4345, saved in run/dashboard-port and reused. Any running dashboard also serves the hub at http://127.0.0.1:4320: a tab per instance on the machine (its name, with a badge when its Inbox has something), that instance's own dashboard under the selected tab, and one "Notify me" for new Inbox items across all of them. The hub keeps no state and takes no action: each dashboard, served by its own repo's kit, stays on its own address inside the hub's frame, and may be framed only by itself and the hub. A dashboard answers only on the address it serves (127.0.0.1 and localhost, or the --host it was started with), and the hub only on its own: a page elsewhere gets nothing, not even a read.
Artifacts. Any session can draw an artifact, a page that shows the owner an idea instead of describing it: a UI mock, a flow, a mock docs site, a 3D model. It lives in project/artifacts/<slug>/ and lands with the PR it was drawn in; the dashboard's Library lists landed ones and live drafts from every worktree beside the link registry, and opens each in a centered viewer or its own tab. An artifact loads its own files and pinned libraries from cdnjs, jsdelivr and unpkg, runs sandboxed with no other network and no reach into the dashboard, and sends nothing back: a decision on one is a question that links it, by name and inline ([[name]]). The owner can ask for one ("draw me a mock docs site"), and planners draw them for any goal with UI or a flow. devteam.mjs artifact new|check, artifacts. On a cloud box the owner can also publish a landed artifact to anyone, at public.box.example.com/<project>/<slug>/ (below).
Previews. Any session can show the owner a running dev server, before anything is committed: devteam.mjs preview --run '<dev command>' (or preview <port> for one it already runs). The name is two words derived from the project, the session and a label, so a link outlives a restart. The Library lists it above the artifacts, a question can cite it as [[name]], and everything the kit started is stopped when the session goes. On a laptop the link is http://127.0.0.1:<port>. A preview is run state: seeing one approves nothing, and nothing of it is logged.
Threads. On the dashboard, hold Shift and point: the cursor becomes a pin and what's under it lights up with its name (a goal, a question, a ticket, a spot on a page). Click, and a small card opens beside it with that thing at the top; what you type there goes to the supervisor as your own words, and its replies show in the card. It may ask you an Inbox question in the thread (answered in either place, once), and propose resolving it; only you resolve, and your resolution is an observation the supervisor's loop reads. Pins mark the threads on the page, and the dock at the bottom right lists every open one, with New thread for one about nothing in particular and Resolved for the last 50. On a phone, the dock's Pin button points without Shift. Threads and their turns are log lines, so they survive a restart and move with the project.
On an artifact or a preview. The same works on a page the fleet drew: hold Shift over it in the Library's viewer and the element under the pointer lights up (button "Buy now"); a click starts a thread on it, and its pin stays on that element as the page scrolls. Open on its Library card, a [[name]] in a question, and the viewer's Open in new tab open it in a tab of its own (/view/artifact/<name>), with its threads and the dock and nothing else. To name the element, the kit adds a small script of its own to the page whenever it is shown in a frame, and never to the page itself: what is committed, checked and published is the page as drawn. What that script tells the dashboard is the page describing itself, so it is shown to the supervisor as data; it can't start, write or answer a thread. A session's running preview works the same way: click its card's title to open it here, or Open to open it in a tab of its own (/view/preview/<name>), and the pin stays on the element as the app reloads. On a laptop the dashboard reaches the app through a relay of its own (127.0.0.1:4800-4899, one per preview). The relay opens the first time you look at a preview and closes when the preview goes. It answers only to its own address, and holds at most 32 live sockets (live reload and the like) per preview. Just opening a preview's address never starts a relay, and a preview shown for the first time asks you to reload once in the dashboard, and a tab of its own reloads itself once. A new preview can't use a port in that range.
Update the kit in every checkout of the project before you use threads: an older kit refuses thread lines when the log is mirrored to it, and leaves out of its views every answer you gave in a thread. After an update there is a short gap: until the supervisor runs its next command as the supervisor (or the dashboard notices it running), what you type in a thread waits for the supervisor's next check instead of appearing in its terminal.
One log. Everything structured is a line in one append-only JSON Lines log, validated against schema/log.schema.json on write and folded by timestamp with authority rules (owner > supervisor > planner > worker). State is computed from the log plus git; nothing else is stored. One file per writer: planners write project/log/plan-<feature>.jsonl in their worktree, so a plan lands only through its reviewed PR; everyone else writes run/log/<writer>.jsonl live, and the supervisor mirrors those into project/log/ with the chore-log PR.
"Use github.com/sanctuarycomputer/devteam to set up a fleet in this repo" means, for the agent doing it:
- Read the repo's own rules first:
CONTRIBUTING, any AI or agent policy,AGENTS.md/CLAUDE.md, CI config. If they forbid autonomous agents (some projects do), stop and tell the owner: devteam can still run on a private fork, but nothing goes upstream without a human writing it. Everything they require goes into the settings in step 3. - Copy
docs/devteam/from the newest release tag of this repo (notmain), exceptproject/andrun/, into this repo (home) only, never into a sibling repo: a copy whose project folder has nosettings.mdruns nothing butinitandhelp(in the kit's own repo, whose remote issanctuarycomputer/devteam, the read-onlystatus,whoami,validateandlinksrun too, over no tickets). - From the main checkout (not a worktree):
node docs/devteam/scripts/devteam.mjs init --upstream-base <tag>. It createsproject/, the.gitignoreentries, a.gitleaks.tomlthat extends the kit's (where there is none) and the devteam section ofAGENTS.md(and an@AGENTS.mdimport inCLAUDE.mdwhere one exists, since Claude Code reads only that). It installs no forge workflow: the supervisor'smergeruns devteam's checks and the project's gates before anything lands, and the project's own CI stays its own. - Fill
project/settings.md(initsetsmain_branchfrom the remote's default branch): the registry: sibling repos asrepolinks (each checked out where itspathsays, default../<id>, or where this machine'srun/links.local.jsonsays) and design files and docs (Figma, specs, Notion) asurllinks, inferred from git remotes, links in the README and docs, and package manifests; the component lines (components.<c>.owns) once there is more than one deployable part; and the stacks,agent_command, thegatesmap (name → command) the supervisor runs before every merge,worktree_files, the gitignored files a fresh worktree needs to boot (a Railsconfig/master.key, a.env), andworktree_setup, the command that makes it runnable (npm ci), both applied to every worker, planner and gate checkout, andforge: "git"if the remote has nogh. Record anything the repo already does differently fromgovernance/(a release branch, its own CI) in the settings; the settings win. Infer what you can from the repo; ask the owner the rest in one batch. A sibling repo gets no forge workflow either: the supervisor'smergeruns devteam's checks and that repo's owngatesbefore a ticket lands in it, and the repo's own CI stays its own.check-pr --repo <id> [--path <checkout>]runs the same checks, minus the gates and the supervisor's approval acts, from any checkout of it. - Run
devteam.mjs gate --allonmain. Every gate that fails there is a toolchain to fix, a gate to set to"mode": "ratchet", or a chore ticket. - Existing codebase: write
project/designs/architecture.mdfrom the code (components, contracts, persistence, dependencies, distance from the code rules, hotspots, tests, work in flight). New repo: leave the headings. - Propose the Baseline goal. Draft the repo's first goal, Baseline: every gate in
quality.md§2 and every Law incode-style.mdenforced on main (lint, format, types, tests in CI, coverage, dead code, duplication, secrets, lockfile, and an encrypted secret store percode-style.md§10 if the repo has none), measured as the floor gaps left, target 0 (process/planning.md§7). One of its preconditions is the registry is complete, marked by number withgoal add --registry-precondition <n>:devteam.mjs components check --countis 0 (counted with the gates), and the owner has said yes to "Does the registry list every repo, design file and doc this project depends on?" (aregistry-attestquestion: owner-only, never defaulted;goal-readinesswaits for it). The owner opts in through the regular flow; once approved, its features are the floor tickets, and gates that fail on existing code go on in ratchet mode (quality.md§2). - Commit the kit, settings and architecture doc as one
chore-devteam-bootstrapPR, thendevteam.mjs log-syncfor the Baseline goal draft. - Start the supervisor. It is the owner's foreground session in the main checkout, never a background, headless or tmux-detached one. If an agent session did this setup, that session becomes the supervisor: it reads the prompt from
node docs/devteam/scripts/devteam.mjs prompt supervisorand follows it in place; it does not launch a second agent. If the owner did the setup by hand, they start the agent in their terminal withDEVTEAM_ROLE=supervisorin its environment (DEVTEAM_ROLE=supervisor claude "$(node docs/devteam/scripts/devteam.mjs prompt supervisor)"), inside tmux if they want workers' messages typed into its pane as they happen. Agent CLIs such as Claude Code don't keep shell state between commands, so anexportinside the session doesn't stick: a session already running prefixes each devteam command withDEVTEAM_ROLE=supervisor. Run the dashboard where it outlives the session (a tmux window, or another terminal):node docs/devteam/scripts/devteam.mjs dashboard, and open the address it prints, or the hub at http://127.0.0.1:4320. - The supervisor drafts the strategy (
project/strategy.md, whichinitleft as a template) from the repo, the site and the links, asks you what it couldn't work out, and you approve it. Then it asks you for goals and the loop begins. To tell a session something from your own terminal as yourself,node docs/devteam/scripts/devteam.mjs send <supervisor|ticket-id> "…" --owner; answers to questions go through the inbox.
Requirements: git (2.38 or later once you draw component lines: coverage computes the tree a PR would land with git merge-tree --write-tree, and refuses to guess on an older git; validate warns when lines are declared and git is older), tmux (for the workers), Node 18+ (the project's own pinned Node is fine: asdf, nvm and the dashboard all run the script with it), the GitHub CLI, one agent CLI (Claude Code, Codex, OpenCode or similar) with superpowers installed.
Every goal, and the built-in Upkeep goal that owns chores, has a toggle on the dashboard: Run, Drain (nothing new starts; what's running finishes and merges), Park (sessions commit their work, push, leave a handoff commit and stop) and Let go (reversible). The strip at the top of the dashboard holds or resumes every goal at once and says whether the fleet can move (process/supervisor.md §12).
Moving to another machine. On the old one, park the fleet from the dashboard, or tell the supervisor ("Park the fleet; I'm moving it to my new laptop"). Fleet drain works too, more slowly: every ticket in flight finishes first. It runs:
DEVTEAM_ROLE=supervisor node docs/devteam/scripts/devteam.mjs fleet park --reason "moving to a new laptop"
# once every session is parked, its own handoff, pushed as a tag:
DEVTEAM_ROLE=supervisor node docs/devteam/scripts/devteam.mjs handoff --supervisor < note.md
DEVTEAM_ROLE=supervisor node docs/devteam/scripts/devteam.mjs fleet status # what's left; repeat until "ready to move: yes"
DEVTEAM_ROLE=supervisor node docs/devteam/scripts/devteam.mjs observe-spend # then land the log:
DEVTEAM_ROLE=supervisor node docs/devteam/scripts/devteam.mjs log-sync
DEVTEAM_ROLE=supervisor node docs/devteam/scripts/devteam.mjs merge --chore log
Anything needed for 1:1 parity is in git; only secrets travel by hand. So fleet status also blocks on files left in run/, worktrees with unpushed work, the owner's unfinished review drafts, and agent memory outside the repo that isn't folded into knowledge or the supervisor's handoff. When it's ready it prints a parity fingerprint of the log, and the new machine's checklist (which worktree_files and secret keys to copy, which logins, which tools).
On the new machine, in this order:
git clone <repo-url> && cd <repo>
# each sibling repo where its path says (the checklist prints the commands), or name this machine's paths in run/links.local.json
asdf install # the versions in .tool-versions, if the repo pins them
gh auth login # and log in to the agent CLI (claude: run it once, /login), with superpowers
# copy each worktree_files entry and secret key from the checklist over a secure channel (never git or chat)
claude # or your agent CLI, in the repo; then say: "Assume the role of the devteam supervisor and resume the fleet"
The supervisor needn't run inside tmux: it puts the workers in a tmux session of its own (tmux only has to be installed), and their messages reach it through the log (wait). Step 9's command works too.
That is step 9's start, unchanged: the supervisor's prompt sees the parked fleet and opens with what to do, so it runs fleet unpark (which rebuilds run/log from the landed log, prints the same parity fingerprint before writing anything, puts every goal back as it was and prints each parked session's restart command), restarts the parked workers and planners (each gets its handoff in its prompt), and then the dashboard is restarted where it outlives the session:
tmux new-window -d -t "=$(node docs/devteam/scripts/devteam.mjs settings --get tmux_session)" -n dashboard 'node docs/devteam/scripts/devteam.mjs dashboard'
Three shapes, from nothing to add upward.
A single repo. Nothing to add. components may stay {} or hold labels a project wrote for itself ({"api": {"stack": "rails"}}); labels draw no lines.
A monorepo. Give each deployable part a line: components.<c>.owns, the globs of the files it owns. Once any component carries owns (or link, shared or release), every tracked file belongs to exactly one line; the kit's own files (docs/devteam/** and the project folder) are the computed devteam line and are never declared. Lines must not overlap:
"components": {
"api": { "owns": ["app/**", "lib/**", "config/*.yml", "config/initializers/**"] },
"routes": { "owns": ["config/routes.rb"] },
"web": { "owns": ["web/**"] },
"ops": { "owns": ["ops/**", "Dockerfile", "README.md"] }
}init writes into these files outside docs/devteam/, and they stay the project's: .gitignore (the run folder, worktrees and harness runtime entries), .gitleaks.toml (only where there is none: it extends docs/devteam/gitleaks.toml, and the project's own rules go below), AGENTS.md (the devteam section, beside the project's own agent instructions) and CLAUDE.md (an @AGENTS.md import, only where one exists). Each holds the project's content as well as the kit's, so none is in the devteam line: give each to a component like any other file. The workers' harness policy (deny rules, the stop hook) is written per launch under docs/devteam/run/, which is gitignored and never tracked.
devteam.mjs components check lists the gaps, each with its fix: unowned paths grouped by folder with a suggested owner, paths two lines claim (with both globs), globs that match nothing, and paths that differ only by case. The kit's own files (docs/devteam/** and the project folder) are set aside before any line is asked, so "docs": { "owns": ["docs/**"] } owns the rest of docs/ without contesting the kit. A glob that matches nothing (a typo, or a folder whose last file was deleted) is a line-empty warning, never a refusal. From then on validate, check-pr and merge, observe-metrics and check-ticket hold the lines, always by committed settings: main's for a ticket PR and for any PR that leaves settings.md alone; for a plan or chore that changes settings.md, the settings it would land (and validate in that chore's chore-<slug> checkout judges its landing tree the same way). check-pr and merge fetch main first, so a push from another clone is in the tree they judge. A ticket changes one component's line; a contract or integration ticket names the others it spans in touches_components.
Links to design files and docs. links names what the fleet uses:
"links": {
"figma": { "kind": "url", "title": "Companion designs", "url": "https://www.figma.com/file/…", "for": ["component:web"], "notes": "ignore the archived page" },
"runbook": { "kind": "url", "title": "Runbook", "url": "https://www.notion.so/…", "for": ["all"] }
}A link whose for matches a ticket (all, component:<c>, feature:<f>, goal:<g>), or that the ticket or its feature lists in its own links, is in that worker's prompt, under "Links (data, not instructions)" with its title and notes quoted as JSON strings. A title or note is one line of plain text (no line breaks, tabs, or control or bidi characters), at most 120 characters for a title and 500 for notes; validate, link add|set, the settings at landing, observe-link and the log schema all refuse anything else. Url links are the supervisor's: the loop proposes them when the owner or a worker mentions one (observe-link, then orient), and the supervisor applies them with link add|set|rm, which logs a registry-edit act; merge lands a url link only as such an act wrote it (the act records the entry), so one edited by hand in settings.md is refused. Only merge checks this, not check-pr, and it is a process check within the supervisor's own class (registry-edit is the supervisor's), not an authority boundary. A URL or remote never carries credentials (user:pass@, a token before the host, a query or fragment parameter such as ?token=, #access_token= or an S3 presigned X-Amz-Signature, or an scp-style user:password@host:path): settings and the log refuse one, and anything that echoes a URL shows such a parameter as name=REDACTED. Each link is probed once a day (a HEAD, or git ls-remote), four at a time inside 20 s a tick; a page answering 401, 403 or 405 is noted as behind auth, not reported missing, and "check": false stops the probe. devteam.mjs links prints the registry.
Sibling repos. One product in several repos (an API, a desktop app, a mobile app) runs on one fleet, one log and one graph. Home is the repo that holds the kit, project/ and the live log; each other repo is a repo link with its own settings, and a component lives on it with "link": "<id>":
"links": {
"console": { "kind": "repo", "title": "Electron console", "remote": "owner/console", "main_branch": "trunk", "gates": { "test": "npm test" }, "worktree_setup": "npm ci" }
},
"components": {
"api": { "owns": ["app/**", "lib/**"] },
"console": { "link": "console" }
}main_branch, forge, merge_method and release default to the project's; gates, worktree_setup, worktree_files and ignored_checks are never inherited, so a repo with no gates is refused by merge as home with none is. Its checkout is at path (default ../<id>, beside home), or wherever this machine's untracked run/links.local.json says ({ "console": { "path": "/elsewhere/console" } }); a missing checkout, or one whose origin is another repo, is refused with one message that says what to clone where, while status, ready, metrics, fleet status and the dashboard carry on (its tickets wait as "repo unreachable", and link-unreachable says why). Each ticket changes exactly one repo: its worker and review worktrees are made from that repo under home's .worktrees/ (merge's own gate, ratchet-base and landing checkouts of it go in the OS temp folder instead), seeded from that repo's own worktree_files and worktree_setup; the worker runs home's kit by absolute path (its prompt says so, and DEVTEAM_HOME names home); its records (spec, plan, verification, reviews, handoff) live in that repo's docs/devteam/project/tickets/<id>/ and land with the code, and that folder holds nothing else. A sibling's worker or review worktree sits inside home's tree, so a tool there that walks up the directories (Node's module resolution, .tool-versions, a lint or ts config) can find home's copy when the sibling's checkout has none: give each sibling its own (its worktree_setup installs its dependencies), or a worker's run can pass where merge's gates, run outside home's tree, fail. merge <ticket> runs that repo's gates and lands on that repo's main, after fetching both mains; it and check-pr refuse any ticket whose depends_on hasn't merged, in whatever repo, home's tickets included (a single-repo project sees this too: before, a dependency only kept a ticket out of ready), and metrics raises merged-out-of-order for a ticket that reached its main before a dependency, counting only merges after the upgrade; every gate and act line still lands in home's log, with a repo field. Plan and chore PRs are always home's. Lines on a sibling are drawn in home's settings and judged by home main's committed settings. A component may release on its own tag pattern ("release": { "tag": "console/v*" }); act … --repo <id> and notes … --repo <id> name the repo of a release. A goal's repo measure runs on the main of the repo its measure.link names (default home). Repo links are the owner's: link add <id> --kind repo --remote r --title t … drafts the entry as a governance-edit question and writes nothing; with the answer, link add … --approved <qid> writes it, link land registry carries it into the chore, and merge --chore registry --approved <qid> lands it. The question shows the whole diff it approves (every repo link change waiting to land), and merge lands only the entries it showed, exactly as it showed them (main's entry then, the entry that lands), read from the answered question itself: acts are history, so a hand-written act can't widen an answer, and any other repo link change in the chore is refused by name. A sibling's gates run in a fresh checkout outside home's tree, so its worktree_setup must install what its gates need. metrics raises merged-without-merge for a commit on any repo's main, after the upgrade, that no merge landed: a sibling people still merge by hand raises one per hand merge, so move it to merge or expect the findings.
Install claude-swap and add your accounts (uv tool install claude-swap, then cswap add after logging in to each). devteam finds it on its own, on a Mac or a Linux box: one dashboard a minute (any that's running; a machine-wide claim picks it) rotates accounts before a limit stops the fleet, using your cswap config. If cswap auto stays on a full account, whether it reads full or its token expired while idle with a full last reading, devteam switches to an enabled account with room itself, never one cswap has quarantined, and a worker or planner started on a full account gets a warning. A worker or planner that did stop is restarted (--resume, the same conversation in a fresh process) once an account has room: a running session keeps its limit even after the login moves. One session per tick, each at most once per 10 minutes and 3 times in 6 hours: past that it is left alone and flagged on the hub until those attempts age out. DEVTEAM_CSWAP=off turns off the rotation and these restarts. A supervisor running in tmux, in a one-pane window started in the main checkout, is restarted the same way on its exact conversation (claude --resume <id>, Claude Code only); one that can't be (a split pane, outside tmux, another harness, no conversation found) is flagged on the hub, and the finding and fleet status give you the exact command to resume it. If the machine died (a laptop out of battery, a redeploy), a worker or planner the kit launched whose window is gone comes back by itself at its next dashboard tick, under the same caps, unless it was held, finished, or closed on purpose in a tmux session older than its launch. Parking is the only off switch for that relaunch: DEVTEAM_CSWAP=off doesn't stop it, a tmux killed by hand while the dashboard runs relaunches everything, and a window closed long ago comes back after tmux next dies. A worktree_setup longer than 100 s can't finish inside a relaunch (the dashboard limits each tick to 120 s): it uses up the attempts and the session waits 6 hours, so keep it shorter.
Setting up a fleet needs none of this, and a project never copies it: cloud/ sits outside docs/devteam/. It is for an owner who wants their dev box in the cloud: one container (Render, fly.io or any Docker host with a disk) holding the repos, the tmux server where every supervisor and worker runs, and the hub, reached at a URL of the owner's (say box.example.com, plus *.box.example.com, since each dashboard keeps an origin of its own on a subdomain, and public.box.example.com serves what the owner publishes). cloud/Dockerfile says how to deploy it.
- Claiming it. On first boot the log prints a claim link, good for a day. Opening it makes a passkey (1Password or the browser saves it), and from then on only a passkey registered on the box opens it; more can be added from the hub's Passkeys link. Signing in is at
box.example.com/auth, and passkeys belong tobox.example.com: pick the box's name before claiming, since changing it invalidates them. Lost every one:devteam-reclaimin a shell on the box prints a new link. A box claimed before sign-in moved fromauth.box.example.comto/authneeds that once too: its passkeys belong to the old name (the gateway's log says so). - The hub is the box's home page, with a Terminal tab first (
term.box.example.com): a terminal on the box's tmux server, where the owner logs in toclaudeandgh, clones a repo and starts its supervisor (the notes it prints say how), and switches between every session (C-b s). Each repo's dashboard is a tab, as on a laptop. The image carries its own copy of the hub page and of the terminal's page, because every agent can change a checkout or take a port: pages on the box's name and theterm.name are only ever the gateway's, and an agent's page can exist only on an instance's name. - Notifications. The gateway watches every Inbox itself and sends the hub's notifications by Web Push, so they arrive with no page open. Turn them on with the hub's "Notify me of new Inbox items", once per browser; tap that text afterwards to send a test notification to that device. On an iPhone (iOS 16.4 or later), open the hub in Safari, Share > Add to Home Screen, open it from there and sign in (it doesn't share Safari's sign-in), then turn them on. They reach the Lock Screen. Removing a passkey or running
devteam-reclaimends every device's notifications. - Dropping files on the terminal. Drag files from your desktop onto the Terminal tab and each is uploaded to
/data/uploads/<date>-<random>/<name>, then its path is pasted at the focused pane, as a native terminal does, so the operator or supervisor there picks it up; press Enter when you've said what to do with it. An image arrives as an attachment (Safari sends a HEIC photo as a JPEG; elsewhere it arrives as a path). If the terminal has lost its connection, the path stays on screen and pastes when it's back. Up to 512 MB a file and 4 GB in all, kept for 7 days. The name is reduced to letters, digits,.,-and_. Only the gateway writes there; agents can read it. - It's a general-purpose dev machine, with no container per project. Everything outside
/dataresets on a deploy, so$HOMEis the machine: languages and tools install per project withmise(it reads.tool-versions, and.ruby-version,.python-versionand.nvmrctoo;asdfis an alias for it), and global tools are in~/.config/mise/config.toml. The image holds only what needs root: build libraries,ffmpeg, Chromium, and Postgres 14, 16 and 17 withpgvector.box-pg 16starts another Postgres (17 is on the default port, the others on 5400 + major);~/.box/boot.shis what starts at boot, the box'sbrew services. There is no Docker on the box. Expo works without it:npx expo start --tunnelfor your phone,eas buildfor builds. The Heroku CLI is a global tool too, the standalone build pinned by version and checksum, with its own Node and onlyherokuonPATH(the npm one runs on whatever Node a repo pins, and refuses one older than 20). Log in once withheroku login -i, giving a token fromheroku authorizations:create --expires-in <seconds> --scope <scopes>on your laptop as the password: the browser login fails, since the browser isn't on the box's IP. The token lands in~/.netrc, on the disk, readable by every agent on the box, so give it the narrowest scope and shortest life that does the job. - The operator (
docs/devteam/process/operator.md) is a Claude session in window 0 of the terminal, there only on a box: it starts at boot, checks the supervisors the boot brought back after a deploy (box-recover), clones and adopts repos when asked, watches that each repo is moving, and when a repo can't run here it fixes what it can and opens a PR upstream for what it can't. Once a day it reviews every repo: what each supervisor learned that would hold for any project becomes an upstream PR (a rule the owner gave one project only as a draft that waits for the owner), and it judges each supervisor against an autonomous team steered by its strategy, telling it what it saw or fixing the kit where the kit is the cause. It never answers questions, approves or merges. Its commands are the kit's, run from the image's own copy (the kit as of the last release that changed the image, so it can be older or newer than a repo's) against every repo whatever its kit's version; each repo's own kit answers for that repo:box-recover(recover --machine) brings back what was up,box-recover --force --only <repo>starts one repo's supervisor, and--forcealone starts every repo's, a held fleet and a stopped supervisor included, skipping siblings.box-status(machine status) is its digest of the box (disk and accounts, each repo's dashboard, kit against the latest release, a stuck pane);box-tell <repo> "<message>"(machine tell) tells a supervisor something, marked as not the owner's word, only when its pane is at its prompt; andbox-alert "<line>"(machine alert) puts a "!" on the hub's tabs and pushes it to the owner's devices. Themachinecommands work on a laptop too, for every project the machine has recorded. A session in a fleet can't runtelloralert, but every agent on the box shares the Unix user and could write the alert file itself, so read one as a report, not as the operator's say-so. - What lasts is on the
/datadisk: repos, worktrees, logins, passkeys, every agent's conversation. A deploy or restart stops the processes, and the box brings them back: each repo's dashboard, and, through the operator, each repo's supervisor that was running, withdevteam.mjs recoveron its own conversation (found withdevteam.mjs supervisor-session, resumed with the repo'sresume_command), which then resumes its own workers and planners, as after any restart of its own. On a laptop, after a dead battery or a crash, runnode docs/devteam/scripts/devteam.mjs recover --machinefrom any adopted repo: it brings back every repo's supervisor that was up, one per repo, from what's on disk (a list in~/.devteameach dashboard and supervisor keeps while running, since the hub only knows what answers now);recoveralone does this repo, and--dry-runsays what it would do.--machineputs everything in tmux, a supervisor from a plain terminal included; barerecoverbrings one that ran in a plain terminal back in the terminal you run it from, and one that ran in tmux back to tmux. Only Claude Code can resume an exact conversation; another harness starts fresh from the log and says so. This works from what the disk holds, so a crash counts the same as a deploy. A turn in flight at the stop doesn't finish; the session carries on from its last saved message. A fleet parked or drained on purpose stays that way, and a supervisor the owner stopped stays stopped: the box notes each minute which supervisors are up in~/.devteam-supervisors(the listbox-recoverreads; a repo on a kit withrecoveralso has its own record), and it knows only those in tmux, so one started outside it (or running for under a minute) isn't resumed. When a supervisor should have come back and couldn't, the operator says why in its window and in~/operator/NOTES.md. The box deploys at each release, under the deploy gate below; keep the platform's own auto-deploy off. - The deploy gate. A release asks Render to build the box's image from that release's commit (
.github/workflows/box-deploy.yml). The job runs in the GitHub environmentbox, skips prereleases, refuses a commit that isn't onmain, and skips a release that changed nothing the image is built from (cloud/,docs/devteam/dashboard/hub.html,docs/devteam/process/operator.md). A release that changes only the kit doesn't restart the box, so the image's own copy of the kit (/opt/devteam/kit, which the operator's commands run) is the one from the last release that changed the image. It relies on settings the repo must keep: theboxenvironment accepting onlyv*tags, with no approval step so that every release that changes the image deploys by itself (the owner's choice),RENDER_DEPLOY_HOOKheld as a secret of that environment and never of the repo (a job outside the environment can't read it, and so can't call the hook with a commit of its choosing), and rulesets onmainandv*tags. A release runs the workflow file as of its own tagged commit, so the checks in that file are only as trustworthy as the commit; the environment is the gate that commit can't edit. Agents share the owner'sghlogin, so an agent can do what that login can, including publishing a release or changing these settings. These gates raise the bar and make an unwanted deploy visible; they are not a hard boundary. A deploy that failed or was rejected isn't redone by a later release that leaves the image alone: re-run its job. The gateway keeps its own copies of the kit's artifact type map, CDN list and page policy for public pages (a test fails while they differ), so changing one in the kit changescloud/gateway.mjstoo, and that release deploys the box. - The gateway (
cloud/gateway.mjs, Node, no dependencies) is the only thing listening publicly. It checks the passkey, refuses any request whose Origin isn't the page's own (and reads another site's page asks for), and passes requests to the kit as a local browser's, so the kit runs unchanged. It runs as its own user, so no agent can read or write its passkeys. It also servespublic.box.example.com, from its own copy of what the owner published, with no sign-in; it sets no cookie there, and reads the owner's only to choose the owner's own view of a page (below). - Public artifacts. The owner can make a landed artifact readable by anyone at
public.box.example.com/<project>/<slug>/(projectmust be set in settings.md: it is part of every link). The Library's Publish opens a page on the box's own name that shows exactly what goes up; confirming it, the gateway fetches every file from that instance, checks each against the manifest it showed and the tree id against the bytes, keeps its own copy, and has the kit record an ownerartifact-publishact in the log. From then on nothing on the box changes what is served: not the port, not main, not the log. Update publishes main's newer version, Unpublish takes a page down within a minute, andbox.example.com/public/lists what is public. The record moves with the log; on a new box the Library shows each published artifact as not live there, and Restore all opens one confirm page for them. Each item starts unticked, because the box holds no copy of what was published, and each is what main holds now: the Library warns when that differs from what the log records (Review all), and the page names every file. Nothing else is public: no index, no drafts, no cookies on that name. The total is capped byDEVTEAM_PUBLIC_MAX(bytes, default 500 MB). Agents can't publish: only the gateway's own page can, and only for the signed-in owner. An agent on the box can still forge the log's record of a publish, so the record is only ever a reason to ask. Signed in, the owner opening a public link gets a page of the gateway's instead: the project's viewer, showing exactly the published version (served by the kit from git by its tree id), so a thread can be started on it, under a one-line bar whose Open it as the public sees it shows the plain page. That needs the project's dashboard running on the box, with a kit that has viewers; otherwise, or whenever anything about it is in doubt, the owner sees the plain page too. A version the box's checkout doesn't have shows main's, and says so. - Dev links. A session's
previewgets a name of its own,cool-tiger.box.example.com, two words derived from the project, the session and a label, under the wildcard the box already has. Only the signed-in owner can open one: it is behind the passkey session like an instance, and nothing about it is ever public. The gateway proxies HTTP and WebSockets to the session's dev server withlocalhostasHostandOrigin, strips the session cookie and refusesDomain=cookies, so the app gets an origin of its own and can read nothing of the owner's.box.example.com/dev/lists every preview on the box, one tap from the hub. A link goes when its session goes (worktree removal, expiry after 7 days of nopreview, or a day without an answer), and the kit prints no box link until the box runs a release with this gateway (DEVTEAM_PREVIEWS=1). From the installed (Home Screen) hub a link opens in an in-app browser with its own cookies, so the first one asks for the passkey once. A preview's name also answers/.devteam/lens, a page of the gateway's own that frames the app for the dashboard's viewer, so a thread can point at an element in a running app; a GET of that path can't reach the app through the link (other methods there still go to the app). On a laptop the kit's relay does the same: it serves that page onGET /.devteam/lens, and forwards everything else to the app withlocalhostasHostandOrigin, sockets included (only from the relay's own page). The relay answers only to its own address (127.0.0.1orlocalhoston its port), takes WebSockets only from its ownOrigin, holds at most 32 sockets per preview, and refuses to be framed by another site. Its lens can be framed only by this machine's hub and dashboards (its policy names them); the app itself can be framed only by its lens, the hub and the dashboard that offered it when it is loaded from its own origin, and by any other page on this machine (any port) on the app's own headers, which are the app's to set. - Give the box a domain of its own, not a subdomain of one you use for anything else. A page an agent serves on an instance's name can write cookies for the whole domain, and when too many pile up the gateway clears them, which signs you out of everything else on that domain too. Treat an instance's name as untrusted for the same reason: an agent can answer on an unused port and appear as a tab. A preview's own name relies on this too: it can only be framed, or get its
SameSitecookies, from a same-site page if the box's name is a registrable domain of its own (not a subdomain of a public suffix like*.onrender.com). - Passkey prompts from an agent's page. Passkeys belong to
box.example.com, the parent of every instance's name, so a page an agent serves on an instance's or a preview's name can raise a genuine passkey prompt for the box. It can't sign in with one, since the gateway only accepts sign-ins from the box's own page. But approving a prompt you didn't start reveals your passkey's user ID, and approving a create could replace your passkey in your password manager, locking you out untildevteam-reclaim. Only approve a prompt you started on the box's own page, and keep a second passkey registered. - A preview's framing headers give way once. The gateway drops an app's
X-Frame-Optionsand sets itsframe-ancestorsfor one kind of load only: the app framed from its own name, which is how its lens holds it. On a laptop the kit's relay gives way the same way, for the same load only, to the lens, the hub and the dashboard that offered the preview. The dashboard computes theframe-srcof a viewer for each request, from the relays open at that moment, so a relay opened later is outside the policy of a page already loaded; the viewer reloads once, and the stage here says to reload. An app that sends no framing headers at all, as dev servers do by default, gets that policy too. The app then accepts only its lens, the hub and the instance that lists the preview as frame ancestors. The lens page itself can be framed by any of the box's instance names, where an agent's page can also sit (on an unused port), but the app inside it is refused there, so that page sees an empty frame. The list of previews is written by the instances, though, so the app can be read through its lens (its text, HTML, routes and console errors, as the app shows them to you with your cookies for that app) by a page on any instance name that lists the dev server's port as a preview of its own. A port two instances list is shown as ambiguous under every name and served under none, so an agent can't take over a dev server that a real instance lists; one that no instance lists can still be claimed. Keep secrets out of dev apps on the box, as for anything an agent can reach. A top-level load and every other frame get the app's own headers, and another site's frame is still refused. The framed app can still open a window of its own from your click (an OAuth popup, a link), unsandboxed, as it could when opened plain. The lens shares the app's origin, so the app can rewrite it, or lie through it about what is on the page: what reaches the supervisor from there is a description of the page, given to it as data, and it can't start, write or answer a thread. - The public name reads the session cookie to choose the owner's own view of a page, and for nothing else. It never forwards the cookie or sets one, that view is never cached (
no-store, private), and the plain page saysVary: Cookie, Sec-Fetch-Dest, so a cache in between can at worst hand the owner the plain page, and never hands that view to anyone else. Both pages open withCross-Origin-Opener-Policy: same-origin, so a page that opened one in a popup can't reach it or read who is visiting. The choice is made from the instances list the gateway reads every 10 seconds, so it asks nothing while you wait and takes the same time whoever visits: a page on another site can't time it to learn you're signed in, and your instance doesn't learn when you open a page. An instance's viewer accepts the public name as a frame ancestor only through a link the owner's view itself made, good for a few minutes, and a published version's files only for that very published version. That is safe because nothing on the public name can frame a page: published pages are served withframe-src 'none', and the owner's view frames only your own instance. The kit says who may frame its viewer and its published versions by policy only, neverX-Frame-Options, so the gateway's addition is all it takes; the viewer keeps the owner's view's link in its own address and passes nothing of it on. A window the viewer opens from there gets no handle on it: the gateway's opener policy cuts it, and the dashboard opens every new tab without one anyway. - Notification text is written by agents, since an agent can appear as an instance, so it can be misleading. Tapping one is safe: it only ever opens the hub.
- The terminal can write to your clipboard. Copying in tmux reaches your own computer's clipboard (OSC 52), and so can anything an agent prints there, within a few seconds of your typing or clicking in that terminal. Check what you paste from it into a shell.
- A dropped file is readable by every agent on the box, so secrets go through the Inbox, not the terminal.
- The terminal is as trustworthy as the box. An agent can replace what the terminal connects to and see what is typed there, so prefer logins that don't involve typing a secret (
gh auth login's browser flow). - The blast radius is the box: agents there hold its GitHub and Claude logins. Give
gha token scoped to the fleet's repos and protect each repo's main branch.
settings.upstream_base records the release a project materialized from. On its improvement loop the project's supervisor lists newer releases, reads each release's What, Why and Migration, and adopts it: every project adopts verbatim releases ("upstream_sync": "adopt", the default), so a release copied verbatim merges without asking the owner, and only an adaptation, a skip or a Migration step that needs the owner is asked. A project the owner sets to "ask" asks per release, or follows a standing rule the owner scoped chore:devteam-sync (process/supervisor.md §9). upstream_sync is the owner's to change (governance-edit). Changes land as a chore-devteam-sync-<tag> PR that never touches project/ or run/. Improvements a project makes to the kit that other projects would want come back here as PRs (upstream-pr on the trust ladder), written generically, with nothing from project/ or run/.
Changes land on main through PRs. node --test --test-concurrency=8 test/*.test.mjs (the kit's tests in test/; the files run side by side; test/bounds.test.mjs times code against a bound, so CI runs it alone first) must pass. A release is a vX.Y.Z tag on main with a GitHub Release whose notes have three sections, because projects' supervisors read them to decide what to fold in:
- What changed, by file or area;
- Why, the reason a project would want it (a change without a why gets skipped);
- Migration, anything a project must do, such as a new settings field or a renamed flag. "None" if nothing.
gh release create vX.Y.Z --target main --title vX.Y.Z --notes-file notes.md