Skip to content
commission

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-2
myapp-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-ingest

prepare 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

strategywhat it doeswhen
in-place (default)nothing at all — you work in the checkout you are inone agent, or work that cannot conflict
worktreea git worktree per task, on the task’s canonical branchseveral 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: 2 gives 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.

providerneedsreadskey configauth
dotenvnothing — no CLI, no account, no networkKEY=VALUE files in the checkoutfiles: [".env", ".env.local"]none
execwhatever your command needsstdout of any command that prints KEY=VALUErun: "chamber export --format dotenv staging"your command’s
gitenvev CLI (npm i -g @rowlabs/ev).env files materialized by ev pullapp, envev pull app:env; filesev login
dopplerdoppler CLIdoppler secrets download --format envapp--project, env--configdoppler login
onepasswordop CLIa committed template of op:// references, resolved by op injectpath: ".env.template", app--accountop signin
vaultvault CLIvault kv get -format=json (KV v1 and v2)path → secret path, app-mountvault 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 unused

Within 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.secrets

Capability 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.providers

Detection 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-2

Every command on this page is executed by tests/docs.test.ts against a scratch database and a scratch git repository.


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.