Workspaces
You will be able to give each task its own place to happen — a directory, a branch, an environment, and ports that do not collide — and choose where the environment comes from without commission caring which vendor you picked.
Audience: anyone running more than one agent in one clone, or anyone whose project needs secrets to run. Assumes: parallel work.
What a workspace is
Three commands:
commission workspace prepare myapp-2
commission workspace list
commission workspace release myapp-2myapp-2 workspace ready — strategy worktree
path: /Users/you/code/myapp/.worktrees/commission-myapp-2-webhook-ingest
branch: commission/myapp-2-webhook-ingest
env: /Users/you/code/myapp/.worktrees/commission-myapp-2-webhook-ingest/.commission.env
keys: DATABASE_URL, STRIPE_KEY
ports: 4336, 4337
chain: worktree — owns allocatesPorts, createsWorktree
dotenv — owns providesEnv
next: cd /Users/you/code/myapp/.worktrees/commission-myapp-2-webhook-ingestprepare is idempotent: run it twice and the second call returns the same
workspace with reused: true rather than rebuilding anything. The --json
shape is a published contract — agents consume .path directly — and it always
carries envFiles, envKeys, ports, warnings, and chain, empty when
nothing applies, so a consumer never has to distinguish absent from empty.
Two strategies
| strategy | what it does | when |
|---|---|---|
in-place (default) | nothing at all — you work in the checkout you are in | one agent, or work that cannot conflict |
worktree | a git worktree per task, on the task’s canonical branch | several agents in one clone |
in-place doing nothing is the correct implementation, not a stub: a project
with no workspace block gets a fully populated descriptor — repo root as the
path, the canonical branch name, empty env and ports — so an agent consumes the
same JSON whether or not the project ever adopts isolation.
worktree shares the object store, so N worktrees cost N checkouts of source
rather than N clones of history. The branch name is not invented by the
provider; it is the same name commission branch prints and the same name the PR
will come from.
{
"version": 1,
"project": "myapp",
"workspace": {
"strategy": "worktree",
"dir": ".worktrees",
"ports": { "range": [4300, 4399], "perTask": 2 }
}
}Add .worktrees/ to .gitignore. Commission warns if you forget — after the fact.
Ports are part of isolation
Three agents each running bun dev need three ports, and “figure it out” is not
an answer an agent can act on. Allocation is a hash of the task id with linear
probing over fixed blocks:
- Deterministic. A task alone on a machine gets the same block every time.
- Consecutive.
perTask: 2gives you two adjacent ports, never interleaved with another task’s. - Collision-free by construction, not by retry — and commission also probes the host, because its own records cannot see a stray dev server somebody left running.
An exhausted range is a refusal that happens before anything is created, so there is nothing to clean up.
Environment providers, six of them, none privileged
The environment is the one concern that genuinely composes — “secrets from the
vault, plus the boring local defaults from .env” is the shape most projects
have — so workspace.providers is an ordered chain, and every provider in
it contributes.
{
"workspace": { "strategy": "worktree", "providers": ["vault", "dotenv"] },
"providers": {
"vault": { "type": "vault", "path": "myapp/dev", "app": "kv" },
"dotenv": { "type": "dotenv", "files": [".env"] }
}
}The parity table
Six providers, one interface, no privileged member. Each one is the same
envProvider(source) function applied to a different collector, so detection,
ordering, merging, file permissions, and teardown are written once and cannot
drift between them.
| provider | needs | reads | key config | auth |
|---|---|---|---|---|
dotenv | nothing — no CLI, no account, no network | KEY=VALUE files in the checkout | files: [".env", ".env.local"] | none |
exec | whatever your command needs | stdout of any command that prints KEY=VALUE | run: "chamber export --format dotenv staging" | your command’s |
gitenv | ev CLI (npm i -g @rowlabs/ev) | .env files materialized by ev pull | app, env → ev pull app:env; files | ev login |
doppler | doppler CLI | doppler secrets download --format env | app → --project, env → --config | doppler login |
onepassword | op CLI | a committed template of op:// references, resolved by op inject | path: ".env.template", app → --account | op signin |
vault | vault CLI | vault kv get -format=json (KV v1 and v2) | path → secret path, app → -mount | vault login |
Read that table as a claim about parity, negatively stated: none of these is
more built-in than the others, none is a default, and removing any one of them
is deleting its module and its line in one export list. dotenv is listed first
because it needs nothing installed, not because it wins anything.
GitEnv is the best-supported provider and is in no way a privileged one. It
is the same shape as the other five, registered by the same line in the same
table; a test asserts the workspace system still passes with it deleted. “Best
supported” means the mapping onto ev’s own vocabulary was done carefully, not
that commission’s core knows it exists.
exec is the answer to “you do not support my secret manager”. Anything
that can print KEY=VALUE on stdout is already a provider — AWS Secrets
Manager, Chamber, SOPS, a shell script. The four vendor wrappers exist so that a
missing CLI produces a first-class refusal (“install the doppler CLI”) instead of
a generic one (“the command failed”), not because the seam requires them.
Precedence: first declared wins
In "providers": ["vault", "dotenv"], a key defined by both comes from Vault
and the .env value is unused. That is the safe direction — a stale .env from
three months ago cannot shadow a real secret — and it reads the same way as
every other precedence rule in commission’s workspace config. Want the reverse?
Declare the reverse.
Every shadowed key is reported by name in the prepare warnings, so a surprise is visible rather than silent:
STRIPE_KEY is provided by both 'vault' and 'dotenv' — 'vault' wins (it is earlier in workspace.providers); the 'dotenv' value is unusedWithin a single dotenv entry the rule inverts — later files override earlier
ones, the ordinary .env then .env.local convention. That is deliberately
local to one entry.
Secrets never leave the layer
A collector returns values. The provider writes them to one 0600 file and
returns the path and the key names. There is no branch anywhere in that
module that puts a value into a result, so there is no branch downstream that
can put one into the record, the event log, or --json. The containment is
structural, not a rule somebody has to remember.
The file is .commission.env in the workspace unless you set options.out, it
carries a do not commit, do not edit header, and workspace release deletes
exactly that one file — never “env files” in general, because that would
eventually eat somebody’s committed .env.
One gotcha worth knowing before you hit it
gitenv refuses in-place by default. ev pull writes .env files into the
directory it runs in; under strategy: "in-place" that directory is your
checkout, and overwriting a developer’s own .env is not something a task
preparation step should do unasked:
'ev pull' writes .env files into the directory it runs in, and this workspace IS your checkout
fix: use the worktree strategy ("workspace": { "strategy": "worktree" }), or accept the overwrite with "options": { "inPlace": "allow" } on providers.secretsCapability negotiation, in one paragraph
Each of the three capabilities — createsWorktree, providesEnv,
allocatesPorts — is owned by exactly one provider: the first in precedence
order that declares it. Configured providers come first and the strategy comes
last, so an explicitly configured provider that can create a worktree takes that
job away from commission’s own strategy rather than racing it. Every other provider
is told it does not own that capability. The chain and its owners are printed by
prepare and carried in --json, so “who did what” is never a guess.
A provider that ends up owning nothing still runs — it may have setup of its own — and its chain entry says how it contributed.
Setup and teardown hooks
{
"workspace": {
"strategy": "worktree",
"setup": [
{ "name": "install", "run": "bun install" },
{ "name": "migrate", "run": "bun run db:migrate" },
{ "name": "seed", "run": "bun run db:seed", "optional": true }
],
"teardown": [{ "name": "compose down", "run": "docker compose down" }]
}
}Hooks are ordered, never parallel — the first failure stops the rest,
because step 4 running against a half-migrated database produces a worse error
than step 3’s. Every hook has a deadline (timeoutMs, default two minutes) and
a timeout is a distinct outcome rather than a generic non-zero exit. Every hook
writes a workspace event with its command, exit code, duration, and captured
output, so a setup that failed on another machine an hour ago is still
debuggable from commission log.
optional: true downgrades a failure to a warning with the consequence stated —
for steps whose absence degrades the workspace rather than invalidating it.
Failure and rollback
A provider must either complete or throw. If a later provider in the chain fails, the earlier ones are torn down, so no partially-prepared workspace is left behind. The one exception is a provider that adopted something it found rather than creating it: rollback is allowed to be destructive about what this prepare built, never about what it merely found.
Every provider failure names three things — which provider, which step, and the fix:
the doppler CLI is not installed or not on PATH
fix: install it: brew install dopplerhq/cli/doppler (https://docs.doppler.com/docs/install-cli) — or change providers.secrets.type in .commission.json to a provider that needs no CLI ("dotenv" reads .env files, "exec" runs a command you already have), or drop 'secrets' from workspace.providersDetection runs for the whole chain before anything runs. A chain that fetches from the vault, writes a file, and only then discovers the second CLI is missing has done work it must undo; a chain that checks first has not started.
Teardown refuses to remove a worktree with uncommitted work unless you pass
--force, and names the files that would have been lost.
The whole sequence
commission workspace prepare myapp-2
commission workspace list
commission workspace release myapp-2Every command on this page is executed by tests/docs.test.ts against a scratch
database and a scratch git repository.
Related
- Parallel work — why isolation matters once you fan out.
- Worked configurations — the
workspaceandprovidersblocks in context. - Config reference — every
workspace.*andproviders.*key.
Part of Guides.
This page is docs/guides/workspaces.md in the Commission
repository, rendered in place — the site keeps no copy of it. The repository is private, so there is no edit link to follow.