Skip to content
sanctuarycomputerPublic

About

A brutally simple, malleable, progressively autonomous & recursively self improving toolkit for aligning & orchestrating coherent fleets of coding agents in planning, building & deploying massively complex systems... all while running mostly unattended 24/7

Resources

Stars

1 star

Watchers

0 watching

Forks

Latest commit

 

History

1,136 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

devteam

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)

How it runs

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.

Setup

"Use github.com/sanctuarycomputer/devteam to set up a fleet in this repo" means, for the agent doing it:

  1. 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.
  2. Copy docs/devteam/ from the newest release tag of this repo (not main), except project/ and run/, into this repo (home) only, never into a sibling repo: a copy whose project folder has no settings.md runs nothing but init and help (in the kit's own repo, whose remote is sanctuarycomputer/devteam, the read-only status, whoami, validate and links run too, over no tickets).
  3. From the main checkout (not a worktree): node docs/devteam/scripts/devteam.mjs init --upstream-base <tag>. It creates project/, the .gitignore entries, a .gitleaks.toml that extends the kit's (where there is none) and the devteam section of AGENTS.md (and an @AGENTS.md import in CLAUDE.md where one exists, since Claude Code reads only that). It installs no forge workflow: the supervisor's merge runs devteam's checks and the project's gates before anything lands, and the project's own CI stays its own.
  4. Fill project/settings.md (init sets main_branch from the remote's default branch): the registry: sibling repos as repo links (each checked out where its path says, default ../<id>, or where this machine's run/links.local.json says) and design files and docs (Figma, specs, Notion) as url links, 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, the gates map (name → command) the supervisor runs before every merge, worktree_files, the gitignored files a fresh worktree needs to boot (a Rails config/master.key, a .env), and worktree_setup, the command that makes it runnable (npm ci), both applied to every worker, planner and gate checkout, and forge: "git" if the remote has no gh. Record anything the repo already does differently from governance/ (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's merge runs devteam's checks and that repo's own gates before 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.
  5. Run devteam.mjs gate --all on main. Every gate that fails there is a toolchain to fix, a gate to set to "mode": "ratchet", or a chore ticket.
  6. Existing codebase: write project/designs/architecture.md from the code (components, contracts, persistence, dependencies, distance from the code rules, hotspots, tests, work in flight). New repo: leave the headings.
  7. Propose the Baseline goal. Draft the repo's first goal, Baseline: every gate in quality.md §2 and every Law in code-style.md enforced on main (lint, format, types, tests in CI, coverage, dead code, duplication, secrets, lockfile, and an encrypted secret store per code-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 with goal add --registry-precondition <n>: devteam.mjs components check --count is 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?" (a registry-attest question: owner-only, never defaulted; goal-readiness waits 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).
  8. Commit the kit, settings and architecture doc as one chore-devteam-bootstrap PR, then devteam.mjs log-sync for the Baseline goal draft.
  9. 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 supervisor and 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 with DEVTEAM_ROLE=supervisor in 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 an export inside the session doesn't stick: a session already running prefixes each devteam command with DEVTEAM_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.
  10. The supervisor drafts the strategy (project/strategy.md, which init left 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.

Holding goals, and moving the fleet

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'

Links, components and other repos

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.

Several Claude accounts

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.

Optional: running it in the cloud

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 to box.example.com: pick the box's name before claiming, since changing it invalidates them. Lost every one: devteam-reclaim in a shell on the box prints a new link. A box claimed before sign-in moved from auth.box.example.com to /auth needs 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 to claude and gh, 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 the term. 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-reclaim ends 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 /data resets on a deploy, so $HOME is the machine: languages and tools install per project with mise (it reads .tool-versions, and .ruby-version, .python-version and .nvmrc too; asdf is 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 with pgvector. box-pg 16 starts another Postgres (17 is on the default port, the others on 5400 + major); ~/.box/boot.sh is what starts at boot, the box's brew services. There is no Docker on the box. Expo works without it: npx expo start --tunnel for your phone, eas build for builds. The Heroku CLI is a global tool too, the standalone build pinned by version and checksum, with its own Node and only heroku on PATH (the npm one runs on whatever Node a repo pins, and refuses one older than 20). Log in once with heroku login -i, giving a token from heroku 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 --force alone 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; and box-alert "<line>" (machine alert) puts a "!" on the hub's tabs and pushes it to the owner's devices. The machine commands work on a laptop too, for every project the machine has recorded. A session in a fleet can't run tell or alert, 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 /data disk: 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, with devteam.mjs recover on its own conversation (found with devteam.mjs supervisor-session, resumed with the repo's resume_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, run node docs/devteam/scripts/devteam.mjs recover --machine from any adopted repo: it brings back every repo's supervisor that was up, one per repo, from what's on disk (a list in ~/.devteam each dashboard and supervisor keeps while running, since the hub only knows what answers now); recover alone does this repo, and --dry-run says what it would do. --machine puts everything in tmux, a supervisor from a plain terminal included; bare recover brings 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 list box-recover reads; a repo on a kit with recover also 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 environment box, skips prereleases, refuses a commit that isn't on main, 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: the box environment accepting only v* tags, with no approval step so that every release that changes the image deploys by itself (the owner's choice), RENDER_DEPLOY_HOOK held 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 on main and v* 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's gh login, 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 changes cloud/gateway.mjs too, 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 serves public.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>/ (project must 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 owner artifact-publish act 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, and box.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 by DEVTEAM_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 preview gets 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 with localhost as Host and Origin, strips the session cookie and refuses Domain= 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 no preview, 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 on GET /.devteam/lens, and forwards everything else to the app with localhost as Host and Origin, sockets included (only from the relay's own page). The relay answers only to its own address (127.0.0.1 or localhost on its port), takes WebSockets only from its own Origin, 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 SameSite cookies, 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 until devteam-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-Options and sets its frame-ancestors for 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 the frame-src of 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 says Vary: 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 with Cross-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 with frame-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, never X-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 gh a token scoped to the fleet's repos and protect each repo's main branch.

Upgrading a project

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/.

Contributing

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

About

A brutally simple, malleable, progressively autonomous & recursively self improving toolkit for aligning & orchestrating coherent fleets of coding agents in planning, building & deploying massively complex systems... all while running mostly unattended 24/7

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages