--isolation verbatim` | records that it stays on its source's values |
## Hooks
[Section titled “Hooks”](#hooks)
`on_create` and `on_clean` hooks get the worktree's run variables (`COMPOSE_PROJECT_NAME`, `WTM_*` and the declared ports) **only when** `run.toml` declares a job running `docker compose` **and** the worktree recorded its isolation. Otherwise a hook runs with the environment it had before the run module existed. When the worktree's own `.env` sets `COMPOSE_PROJECT_NAME`, that value is the one the hook gets.
## Foreign data and `touches`
[Section titled “Foreign data and touches”](#foreign-data-and-touches)
Some tasks change data: a migration, a reset, a seed. Run against data the worktree does not own, they change it for someone else too. wtm calls that **foreign data**:
* a verbatim worktree's source's data (the two share a database);
* a shared service's data when it declares no `[job.namespace]` (every worktree shares it).
wtm cannot read that from a command, so a job declares it:
```toml
[[job]]
name = "migrate"
kind = "task"
cmd = "pnpm db:migrate"
touches = ["postgres"] # the services whose data it changes
```
`wtm run init` asks it task by task and pre-fills what the names make obvious; `wtm run job add|edit --touches` sets it by hand.
Before starting a job whose `touches` reach foreign data (including a job started by a runner through `runs`), `run up` and `run start` stop and ask. Under `--yes` they refuse and name the way out:
```bash
wtm run up hotfix/prod --yes # refused: migrate would change its source's database
wtm run up hotfix/prod --yes --force # run it anyway
wtm env hotfix/prod --isolation isolated --yes # or give the worktree its own data
```
A `[job.namespace]` on the shared service gives each worktree its own part of it instead (see [Shared services](/guide/shared-services/)).
# Jobs, profiles and runners
```bash
wtm run job add db --cmd 'docker compose up -d' --stop 'docker compose down' --port DB_PORT=5432 --yes
wtm run job add migrate --kind task --cmd 'pnpm db:migrate' --yes
wtm run job add web --cmd 'pnpm dev --port ${PORT}' --port PORT=3000 --yes
wtm run profile add dev --jobs db,migrate,web --default --yes
wtm run up -d # starts the default profile in the current worktree
wtm run ps # what runs, everywhere
wtm run down # stops this worktree's jobs
```
## Jobs
[Section titled “Jobs”](#jobs)
A **job** is the unit wtm runs, declared as a `[[job]]` in `run.toml`. Its `kind` is one of two:
* a **service** is long-running: a dev server, a docker stack. Without a `stop` command wtm tracks its process and stops it with SIGTERM. With one, its `cmd` is a **launcher** (`docker compose up -d`): wtm waits for it to exit, considers the real work owned by something else (Docker), and runs `stop` to bring it down.
* a **task** is one-shot: a migration, a seed. It runs to the end, its output streams live, and a non-zero exit aborts the profile it belongs to.
`cmd` and `stop` are `/bin/sh` lines: quotes, `&&`, pipes and `${VAR}` behave as in a terminal. A job runs in the worktree it was started for (plus its `cwd`), so the same job runs once per worktree, unless it is a [shared service](/guide/shared-services/).
Declare jobs with `wtm run init` (detected from compose files and package scripts) or `wtm run job add`; `wtm run job edit` changes any field from a flag.
## Profiles
[Section titled “Profiles”](#profiles)
A **profile** is a named, ordered group of jobs:
```toml
[[profile]]
name = "shop"
jobs = ["db", "migrate", "shop-api"] # migrate runs to the end before shop-api starts
default = true
```
`wtm run up` starts **exactly one profile**: `--profile`, else the one marked `default = true`, else the only one declared. With several and no default, an interactive run asks (the cursor on the default); `--yes` and runs without a terminal fail naming `--profile`. A `run.toml` declaring no profile starts every job.
| Command | Does |
| -------------------------------------------- | ------------------------------------------------------------------------------------------------ |
| `wtm run up [worktree...] --profile ` | start a profile, in one or several worktrees |
| `wtm run start [worktree] --job ` | start one job, outside any profile |
| `wtm run down [worktree...] [--profile ]` | stop what a worktree runs, or one profile of it; `--all` covers every worktree of the repository |
| `wtm run stop [worktree...] --job ` | stop one job |
## Runners
[Section titled “Runners”](#runners)
In a monorepo, one root script often starts several apps: `turbo run dev`, `pnpm -r dev`, a compose file with several services. Declare that relation on the runner, since wtm never infers it from the command:
```toml
[[job]]
name = "dev"
kind = "service"
cmd = "pnpm turbo run dev"
runs = ["shop-web", "shop-api"]
```
The runner carries its children's ports and named URLs, and wtm will not start an app twice, once by the runner and once on its own. While the runner is up, its children have no row of their own: their addresses are reported under it. The [pnpm/turbo recipe](/guide/recipes/#a-pnpm-or-turbo-monorepo) is a complete setup.
## The run view, or `-d`
[Section titled “The run view, or -d”](#the-run-view-or--d)
`run up`, `run start` on a service and `run logs` open the **run view**: a full-screen view with one pane per job. Leaving it (`q`, or Ctrl+C outside focus mode) **detaches** (the jobs keep running in the background daemon), and `wtm run logs` reopens it.
* `-d` starts the jobs and gives the prompt back instead. A task always runs inline, with or without `-d`.
* No view opens unless both stdin and stdout are a terminal (`wtm run up > run.log` included), nor under `--output json`: the run reports itself as lines, which is what a script or an agent gets.
* Each job's output is also written to `/wtm/logs//.log` (5 MB × 3 within one run), cleared when the job starts, so `run logs` replays a job that is no longer running: `wtm run logs --job api`, or `--output json` for the last 1000 lines of each job.
## Checking the ports
[Section titled “Checking the ports”](#checking-the-ports)
Declaring a port only injects a variable. Once the jobs are up, `run up` and `run start` dial each declared port and report the ones nothing answers on, the sign of a command that never read its variable (an example is in [The port check](/guide/how-run-works/#the-port-check)). The check never fails the run and stops as soon as every port answers.
| Switch | Where | Effect |
| ---------------------- | --------------------- | --------------------------------------------------------------------------------------------------------------------------- |
| `--no-probe` | `run up`, `run start` | skips the check for one run |
| `probe = false` | a `[[job]]` | skips it for that job; an interactive run offers to write it for a warning that comes back every time |
| `port_probe_timeout` | top of `run.toml` | the budget in seconds (15 by default; a negative value turns the check off) |
| `binds_no_port = true` | a service | listens on nothing by design (a watcher, a worker, a runner whose children hold the ports), so wtm stops offering it a port |
## Several worktrees at once
[Section titled “Several worktrees at once”](#several-worktrees-at-once)
```bash
wtm run up feat/a feat/b -d # two stacks side by side, each on its own ports
wtm run up feat/c --exclusive # stop the other worktrees' jobs first
```
The first time `run up` or `run start` finds jobs running in another worktree, it asks what to do about the machine's load, and can remember the answer as `concurrency = "parallel" | "exclusive"` at the top of `run.toml`. `--parallel` and `--exclusive` answer for one run; `--exclusive` is refused on several worktrees, since it stops all but one, and `--yes` alone keeps the others running.
Worktrees that share their ports (a [verbatim](/guide/isolation/) worktree and its source) cannot run together whatever the setting: `run up` and `run start` offer to stop the other one or not to start, and refuse under `--yes` unless `--exclusive` was given.
## `run ps` statuses
[Section titled “run ps statuses”](#run-ps-statuses)
`wtm run ps` lists what the daemon holds across every repository, from anywhere. A runner binds no port, so its ADDRESS is empty: the apps it started are listed under its row with their addresses (`held` in the JSON). A job's `status` is one of:
| Status | Meaning |
| ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `running` | a process wtm started and still watches |
| `detached` | a service whose launcher exited, leaving the work to something wtm does not own (a compose stack); nothing about it is verified, and it survives the daemon |
| `joined` | this worktree's hold on a [shared service](/guide/shared-services/) running in the main checkout; it owns no process |
| `stopped` | stopped on request |
| `crashed` | a service that exited without being asked to (`exit_code` in the JSON says how) |
| `reaped` | a service that outlived the daemon which owned it, taken down by the next one |
The results of `run up`, `run down` and `run stop` use their own vocabulary: `started`, `joined`, `done` (a task that ran to the end), `stopped`, `released` (a shared service this worktree let go of, still up for others), `already_running`, `not_running` (nothing was up under that name) and `error`.
# Migrating to 0.28
0.28 turns the `run` module into a per-worktree dev stack and settles its interface. Most of it only concerns you if you used `wtm run` or `wtm switch` in 0.27, or script wtm.
## Checklist
[Section titled “Checklist”](#checklist)
1. **Open a new shell** after upgrading (or re-run `eval "$(wtm shell-init)"`). The `wtm` shell function now returns the command's exit code; the old one always returned `0`.
2. **Decide the isolation of each worktree created with 0.27**: see [below](#worktrees-created-with-027).
3. **Stop stacks started by 0.27** once, with `docker compose -p down`: they ran under another compose project name, and a new `run up` will not find them.
4. **Re-read your hooks**: they now run through `/bin/sh -c`.
5. **Update scripts and agents**: the [commands](#commands), the [JSON contract](#the-json-contract-of-wtm-run) and the [exit codes](#exit-codes). Re-run `wtm agents install` so your agent's skill describes 0.28.
## Worktrees created with 0.27
[Section titled “Worktrees created with 0.27”](#worktrees-created-with-027)
A worktree created before 0.28 recorded no isolation (no `isolation` in its `meta.json`). It keeps running on its source's ports and compose project, `wtm run up` / `wtm run start` refuse it naming the command to run, and `wtm env --yes` reconciles its `.env` keys but **touches nothing run-related** (no port shift, no `COMPOSE_PROJECT_NAME`). Decide once per worktree:
```bash
wtm env feat/login --isolation isolated # its own ports and a new compose project: its current volumes (_*) are no longer used
wtm env feat/login --isolation verbatim # stay on its source's values
```
The interactive `wtm env` offers the same choice and says what it changes. See [Isolation](/guide/isolation/).
## Hooks
[Section titled “Hooks”](#hooks)
Hooks used to be split on spaces and run without a shell; they now run through `/bin/sh -c`, so `&&`, pipes, redirections, quotes and `$VAR` behave as in a terminal. A shell character that was passed literally (`$`, `*`, `;`, `&`, quotes) now means something.
```toml
# 0.27: no shell, one program per entry, split on spaces
on_create = [{ cmd = "pnpm install", cwd = "apps/api" }]
# 0.28: a /bin/sh line (the object form still works)
on_create = ["cd apps/api && pnpm install && pnpm db:generate"]
```
* `{{worktree}}`, `{{branch}}`, `{{root}}` and `{{from_branch}}` are quoted for the spot they land in, so a path holding `'` or `$` arrives intact: write them without quotes of your own.
* `COMPOSE_PROJECT_NAME`, `WTM_*` and the declared ports reach a hook only when `run.toml` declares a `docker compose` job **and** the worktree recorded its isolation. Otherwise a hook gets the environment it had in 0.27. A `COMPOSE_PROJECT_NAME` set by the worktree's own `.env` always wins.
## Commands
[Section titled “Commands”](#commands)
| 0.27 | 0.28 |
| ------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------- |
| `wtm switch feat/login` | `wtm go feat/login` then `wtm run up` |
| `wtm init --non-interactive` | `wtm init --yes`, now fully unattended, global config included (same for `run init`) |
| `wtm init --only hooks --yes` skipped the confirmation | it regenerates the section without the wizard |
| `wtm run up web` | `wtm run up --profile web`: the worktree is the positional (a branch name, never a path), the profile a flag |
| `wtm run start api` | `wtm run start --job api`, same rule: `run start [worktree] --job ` |
| `wtm run up --profile a --profile b` | one profile per run; with several and no default, `--yes` fails naming `--profile`. A repeated single-value flag is refused |
| `wtm run down --all` stopped every repository's jobs | it stops every worktree of the current repository only |
| `wtm run import` merged into `run.toml` | it replaces the file; `--replace` and its `--force` are gone, and without a terminal it needs `--yes` |
| `wtm run ps` offered an action picker | it lists; `wtm run logs` opens the view that acts on jobs |
Two refusals are new:
* When `run.toml` declares a job, a worktree whose derived name another live worktree already carries (`feat.x` beside `feat/x`: same compose project, same namespaces, same host) is refused at creation, exit `10`.
* `wtm relocate` no longer moves a worktree whose jobs are running (`blocked_jobs`): `wtm run down ` first.
`run.toml` is also validated more strictly: a job or profile name with a space, a `kind` other than `service` / `task`, two port bases a multiple of `port_offset_block` apart, a reference to an undeclared job, or a link on a `.env` file `config.toml` does not provision are refused. A refused file never fails a core command; it only disables the run part, with a warning.
## The JSON contract of `wtm run`
[Section titled “The JSON contract of wtm run”](#the-json-contract-of-wtm-run)
```jsonc
// 0.27: wtm run ps --output json
[{"name": "api", "kind": "service", "status": "running", "pid": 4242, "work_dir": "/code/.trees/feat-login"}]
// 0.28: the worktree is branch + path, with its compose project
[{"name": "api", "kind": "service", "status": "running", "pid": 4242, "branch": "feat/login", "path": "/code/.trees/feat-login", "project": "acme-feat-login"}]
```
* A job object is keyed `name`; any other object pointing at a job says `job`.
* A worktree is always `branch` + `path` (no more `worktree` or `work_dir`).
* `run up`, `run down`, `run stop` and `run logs` always return an **array of per-worktree documents**, however many worktrees there are. `run logs` is `[{branch, path, lines: [{job, at, text}]}]`.
* `run ps` returns `branch`, `path` and `project`, `held` for a runner, and no longer `released`.
* `run addressing` returns `settled`, `pending` and `main_left` as `{branch, path}` objects.
* Result statuses are `started`, `joined`, `done`, `stopped`, `released`, `not_running`, `already_running` and `error`. `stopped` is no longer reported for something that was not running. `attached` is now `joined` (an older daemon's `attached` is still read).
* A command with per-job results writes its whole document, then exits non-zero; one that fails before writes nothing on stdout.
## Exit codes
[Section titled “Exit codes”](#exit-codes)
| Code | 0.28 meaning | Before |
| ---- | ------------------------------------------------------------------------------------------------------------------------------------------- | ------------ |
| `2` | a usage error: unknown flag or subcommand (including `wtm run `), unreadable flag value, unknown `--output` format, extra argument | `1` |
| `14` | a job or profile `run.toml` does not declare, now also for \`run job | profile edit |
| `16` | no `run.toml`; the message says to run `wtm run init` | — |
## The daemon
[Section titled “The daemon”](#the-daemon)
A 0.27 daemon still running is replaced automatically when it holds no job. When it holds some, `run up` refuses and names `wtm run daemon restart`, which replaces it: detached services survive the restart, foreground ones are stopped.
## For pre-release testers
[Section titled “For pre-release testers”](#for-pre-release-testers)
Since the last `0.28.0-beta`: `attached` → `joined`; `--profile` takes one value; `run export` accepts `--output`; `run start` gains `--exclusive`, `--parallel` and `--no-probe`; no view opens unless stdin **and** stdout are terminals (`run up > run.log` no longer opens it); `Esc` in `run init` prints `= Aborted.` and exits with the abort code.
# Migrating to 0.29
## `wtm create --output json` answers with an envelope
[Section titled “wtm create --output json answers with an envelope”](#wtm-create---output-json-answers-with-an-envelope)
`create` can now make several worktrees in one run, so its JSON document is always an envelope, even for a single branch.
Before:
```json
{"branch": "feat/login", "path": "/repo/.worktrees/feat-login", "already_exists": false, ...}
```
After:
```json
{"results": [{"branch": "feat/login", "path": "/repo/.worktrees/feat-login", "already_exists": false, ...}], "failed": []}
```
A script reading `.path` reads `.results[0].path`. A branch that could not be created is in `failed`, with its `error`, its `exit_code`, and its `path` when the worktree exists but an `on_create` hook failed. The process still exits with the first failure's code, so a script that only checks the exit code needs no change.
## `wtm clean --output json` answers with an envelope
[Section titled “wtm clean --output json answers with an envelope”](#wtm-clean---output-json-answers-with-an-envelope)
`clean` can now remove several worktrees in one run, so its JSON document is an envelope too, even for a single branch.
Before:
```json
{"branch": "feat/login", "path": "/repo/.worktrees/feat-login", "already_absent": false, "namespaces": []}
```
After:
```json
{"results": [{"branch": "feat/login", "path": "/repo/.worktrees/feat-login", "already_absent": false}], "failed": [], "skipped": [], "reparented": [], "orphaned_children": [], "namespaces": []}
```
A script reading `.already_absent` reads `.results[0].already_absent`. `reparented`, `orphaned_children` and `namespaces` stay at the top level, each entry naming its `branch`. A worktree that could not be removed is in `failed` with its `error` and `exit_code`; the process still exits with the first failure's code.
## The wizard asks for a list of branches
[Section titled “The wizard asks for a list of branches”](#the-wizard-asks-for-a-list-of-branches)
`wtm create` without arguments now asks for one or more branches: type a name, press tab to add another, enter to continue. `wtm create ` with a single argument skips that step as before; with several arguments the list opens pre-filled. The dashboard's create (`wtm ui`) asks the same list; each worktree appears in the list as soon as it exists, and the cursor lands on the first.
## `wtm clean` takes several worktrees
[Section titled “wtm clean takes several worktrees”](#wtm-clean-takes-several-worktrees)
`wtm clean feat/a feat/b` removes both; without arguments the picker lets you check several. Under `--yes`, one unsafe worktree (dirty, unpushed, open PR) refuses the whole run before anything is removed, so pass `--force` or name only the safe ones. The dashboard (`wtm ui`) offers the same from its global menu, « Delete worktrees »; the row menu still deletes the one worktree it was opened from.
## Two exit codes changed
[Section titled “Two exit codes changed”](#two-exit-codes-changed)
| Code | Now returned by | Before |
| ---- | ------------------------------------------------------------------------------------------------------- | ------ |
| `21` | any command run outside a git repository | `1` |
| `19` | every interactive cancellation: Esc, Ctrl-C, "No, cancel", a declined confirmation. Never under `--yes` | `0` |
`wtm create x && wtm go x` now stops when the wizard is cancelled. A script that tested `1` for "not a repository", or read `0` after a cancelled prompt, checks for `21` or `19`. The full list is in [Integrations](/guide/integrations/#the-contract---yes-and---output-json).
# Platform support
wtm is built for macOS and Linux, on amd64 and arm64. Windows runs it through WSL2, as a Linux.
| | macOS | Linux | WSL2 | Windows (native) |
| ------------------------------------------------------------ | --------- | -------------------------------------- | ----------------- | ---------------- |
| Release binaries | ✓ | ✓ | ✓ (the Linux one) | ✗ |
| Homebrew | ✓ | ✓ | ✓ | ✗ |
| Worktrees: `create`, `clean`, `sync`, `prune`, `exec`, `ui`… | ✓ | ✓ | ✓ | ✗ |
| Shell integration (`wtm go`): zsh, bash, fish | ✓ | ✓ | ✓ | ✗ |
| `wtm run`: jobs, ports, compose isolation, shared services | ✓ | ✓ | ✓ | ✗ |
| Named URLs on the proxy port (`:11080`) | ✓ | ✓ | ✓ | ✗ |
| Named URLs on port 80 (`wtm run proxy install`) | ✓ | ✗ | ✗ | ✗ |
| `wtm upgrade` | ✓ | ✓ | ✓ | ✗ |
| Tested | daily use | CI (`make test` on every pull request) | not yet | ✗ |
## Linux
[Section titled “Linux”](#linux)
Everything but one feature: `wtm run proxy install`, which serves named URLs on port 80 so they drop their `:11080`, relies on a launchd agent and exists on macOS only. On Linux the named URLs keep their port, and `wtm run url --raw` prints the plain `http://localhost:` address. See [Named URLs](/guide/addressing/).
The global config and the run daemon's files live under `~/.config/wtm/` (see [State](/guide/state/)).
## WSL2
[Section titled “WSL2”](#wsl2)
WSL2 runs the Linux binary, with the Linux row above. Keep the repository on the Linux filesystem (`~/…`, not `/mnt/c/…`): git is much slower across the boundary, and every worktree would pay it. wtm never talks to Docker itself: your jobs call the `docker` CLI, so whichever engine that CLI reaches inside the distribution is the one `wtm run` uses.
WSL2 is not tested on every release yet: a report of what works or breaks there is welcome in an [issue](https://github.com/LucasPcq/wtm/issues/new/choose).
## Windows
[Section titled “Windows”](#windows)
Not supported natively. `wtm run` jobs, hooks and `wtm exec` commands are `/bin/sh` lines, and the run daemon talks over a Unix socket and manages process groups: none of that has a direct Windows equivalent. Use WSL2.
# Recipes
Complete setups for common projects. Each one shows the `run.toml` it ends with (in `.git/wtm/run.toml`) and the commands that use it. `wtm run init` writes most of this from detection; the recipes show where to take it by hand, with `wtm run job add|edit` or an editor. Every key is described in the [`run.toml` reference](/guide/run-toml/).
* [A pnpm or turbo monorepo](#a-pnpm-or-turbo-monorepo)
* [A docker compose app](#a-docker-compose-app)
* [One postgres, a database per worktree](#one-postgres-a-database-per-worktree)
* [Several AI agents, each in its own worktree](#several-ai-agents-each-in-its-own-worktree)
* [Stacked pull requests](#stacked-pull-requests)
* [Run a command across worktrees](#run-a-command-across-worktrees)
## A pnpm or turbo monorepo
[Section titled “A pnpm or turbo monorepo”](#a-pnpm-or-turbo-monorepo)
One root script (`turbo run dev`, `pnpm -r --parallel run dev`) starts every app. Declare each app as a job with its own port, then the root script as a **runner** that `runs` them:
```toml
[[job]]
name = "web"
kind = "service"
cmd = "pnpm dev"
cwd = "apps/web"
[job.ports]
WEB_PORT = 3000
[job.url]
port = "WEB_PORT"
[[job]]
name = "api"
kind = "service"
cmd = "pnpm dev"
cwd = "apps/api"
[job.ports]
API_PORT = 4000
[job.url]
port = "API_PORT"
[[job]]
name = "dev"
kind = "service"
cmd = "pnpm turbo run dev"
runs = ["web", "api"]
[[profile]]
name = "dev"
jobs = ["dev"]
default = true
```
* The runner is started with its children's ports in its environment (`WEB_PORT=3010`, `API_PORT=4010` in the first worktree), and their named URLs are published under it. `run ps` lists the runner with the apps it holds beneath it.
* **Give each app its own variable name.** A runner passes one environment to every child, so two apps both reading `PORT` cannot get two values. Each app reads its own: `"dev": "next dev --port ${WEB_PORT:-3000}"` in `apps/web/package.json`.
* **Turborepo filters the environment by default** (`envMode: "strict"`), so the ports never reach the apps. Let them through in `turbo.json`: `"globalPassThroughEnv": ["WEB_PORT", "API_PORT"]`.
* `web` and `api` stay startable on their own: `wtm run start --job api` starts one app without the runner. wtm refuses to start an app its running runner already holds.
`wtm run init` asks, for each root script, which declared jobs it runs. By hand:
```bash
wtm run job add dev --cmd 'pnpm turbo run dev' --runs web --runs api --yes
wtm run profile add dev --jobs dev --default --yes
```
## A docker compose app
[Section titled “A docker compose app”](#a-docker-compose-app)
A compose file whose services each worktree runs on its own. wtm sets `COMPOSE_PROJECT_NAME` per worktree (`acme-feat-login`), so containers, networks and volumes are already separate. What is left is the host ports, which must read a variable:
docker-compose.yml
```yaml
services:
db:
image: postgres:16
ports:
- "${DB_PORT:-5432}:5432"
redis:
image: redis:7
ports:
- "${REDIS_PORT:-6379}:6379"
```
```toml
[[job]]
name = "stack"
kind = "service"
cmd = "docker compose up -d"
stop = "docker compose down"
[job.ports]
DB_PORT = 5432
REDIS_PORT = 6379
[[job]]
name = "api"
kind = "service"
cmd = "pnpm dev"
cwd = "apps/api"
[job.ports]
PORT = 4000
[job.url]
port = "PORT"
[[profile]]
name = "dev"
jobs = ["stack", "api"]
default = true
[[env_port]]
file = "apps/api/.env"
key = "DATABASE_URL"
job = "stack"
port = "DB_PORT"
```
* With a `stop` command, `cmd` is a launcher: wtm waits for `docker compose up -d` to exit and runs `docker compose down` on `wtm run down`. `run ps` shows the stack as `detached`.
* The `[[env_port]]` link rewrites the port inside `DATABASE_URL` (`postgresql://app:app@localhost:5432/app` becomes `…:5442/app` in the first worktree) when the worktree is created, and whenever `wtm env` reconciles it.
* `wtm run init` finds literal host ports (`"5432:5432"`) and absolute names (`container_name`, a volume's `name`) and offers to rewrite them; `--patch-compose` does it unattended. A renamed volume starts empty.
* A `docker compose up` typed by hand in the worktree is isolated too, since the `.env` carries the ports and `COMPOSE_PROJECT_NAME`.
See [How `wtm run` works](/guide/how-run-works/) for compose names and the port check.
## One postgres, a database per worktree
[Section titled “One postgres, a database per worktree”](#one-postgres-a-database-per-worktree)
Ten worktrees do not need ten postgres containers. A **shared** service runs once, in the main checkout, and each worktree gets its own database in it, created on start and dropped on `wtm clean`:
```toml
[[job]]
name = "postgres"
kind = "service"
cmd = "docker compose up -d postgres"
stop = "docker compose stop postgres"
scope = "shared"
[job.ports]
POSTGRES_PORT = 5432
[job.namespace]
name = "app_{worktree}"
create = "scripts/db-worktree-add.sh"
remove = "scripts/db-worktree-drop.sh"
[job.namespace.env]
PGPASSWORD = "postgres"
[[job]]
name = "migrate"
kind = "task"
cmd = "pnpm db:migrate"
cwd = "apps/api"
touches = ["postgres"]
[[job]]
name = "api"
kind = "service"
cmd = "pnpm dev"
cwd = "apps/api"
[job.ports]
PORT = 4000
[job.url]
port = "PORT"
[[profile]]
name = "dev"
jobs = ["postgres", "migrate", "api"]
default = true
[[env]]
file = "apps/api/.env"
key = "DATABASE_URL"
job = "postgres"
value = "postgresql://postgres:postgres@localhost:{port.POSTGRES_PORT}/{namespace}"
```
The two commands are yours; wtm runs them with `$WTM_NAMESPACE` (`app_feat-login`), the job's ports and the `namespace.env` variables. `create` runs on **every** start, so it must do nothing when the database exists:
scripts/db-worktree-add.sh
```sh
#!/bin/sh
set -e
psql="psql -h localhost -p $POSTGRES_PORT -U postgres -v ON_ERROR_STOP=1"
exists=$($psql -tAc "SELECT 1 FROM pg_database WHERE datname = '$WTM_NAMESPACE'")
[ "$exists" = 1 ] && exit 0
$psql -c "CREATE DATABASE \"$WTM_NAMESPACE\" TEMPLATE app"
```
scripts/db-worktree-drop.sh
```sh
#!/bin/sh
set -e
psql -h localhost -p "$POSTGRES_PORT" -U postgres -v ON_ERROR_STOP=1 \
-c "DROP DATABASE IF EXISTS \"$WTM_NAMESPACE\" WITH (FORCE)"
```
* `TEMPLATE app` starts each worktree from a copy of main's data (`app`, the database main's `.env` names), so there is nothing to seed. It refuses while main has open connections; drop the `TEMPLATE` clause for an empty database.
* The `[[env]]` link writes the whole `DATABASE_URL`, pointing each worktree at its own database. `migrate` declares `touches = ["postgres"]`; since every worktree has its own namespace, it runs without a question.
* `run ps` shows the worktrees holding the service as `joined`; it stops once none holds it.
* `wtm clean feat/login` drops the database after removing the worktree; `--keep-data` keeps it. When postgres is down, `--yes` defers the drop to its next start and `--drop-data` starts it to drop now.
The same fields exist as flags: `wtm run job add postgres --scope shared --namespace-name 'app_{worktree}' --namespace-create … --namespace-remove … --namespace-env PGPASSWORD=postgres`. See [Shared services](/guide/shared-services/).
## Several AI agents, each in its own worktree
[Section titled “Several AI agents, each in its own worktree”](#several-ai-agents-each-in-its-own-worktree)
Give each agent a branch, a directory and a running stack of its own. Every command below prompts for nothing, and `--output json` gives it a document to read instead of text:
```bash
wtm agents install # once: the using-wtm skill for Claude Code / Cursor
branch=agent/fix-checkout
wtm create "$branch" --if-not-exists --yes --output json # {"results": [{"branch", "path", "isolation", ...}], "failed": []}
cd "$(wtm resolve "$branch")"
wtm run up "$branch" -d --yes --output json # per-job status, ports and URLs
api=$(wtm run url "$branch" --job api) # http://api.agent-fix-checkout.acme.localhost:11080
curl -s "$api/health"
wtm run logs "$branch" --output json # the last 1000 lines of each job
wtm run down "$branch" --yes
wtm clean "$branch" --yes # add --force once the work is pushed elsewhere
```
* `run up --yes` leaves the other worktrees' jobs running, so agents starting at the same time do not stop each other. Setting `concurrency = "parallel"` in `run.toml` makes that the answer for people too.
* Under `--yes` a missing choice is an error naming its flag, never a picker: `run start` needs `--job`, `create` needs the branch.
* Exit codes are stable: `10` the worktree already exists, `11` the branch does not exist, `12` the repository was never initialized with wtm, `14` a job or profile `run.toml` does not declare, `16` no `run.toml`, `18` a `wtm env --check` that found drift, `19` an interactive run you backed out of (so `wtm create x && wtm go x` stops there), `20` a `wtm events` that received an event of a newer schema, `21` not in a git repository, `2` a usage error. Which of them `wtm events` treats as final is in [The event stream](/guide/events/#when-it-exits).
* `wtm run ps --output json` lists everything running, across repositories, and `wtm list --output json` every worktree with its state.
* `clean --yes` still refuses a worktree with uncommitted or unpushed work; that refusal is lifted only by `--force`.
`wtm agents install` adds a skill to `.claude/` or `.cursor/` that teaches the agent these commands. Re-run it after upgrading wtm.
## Stacked pull requests
[Section titled “Stacked pull requests”](#stacked-pull-requests)
Each branch builds on the previous one, and every worktree records its parent:
```bash
wtm create feat/api --yes
wtm create feat/api-client --from feat/api --yes
wtm create feat/checkout-ui --from feat/api-client --yes
```
```console
$ wtm tree
main
└─ feat/api
└─ feat/api-client
└─ feat/checkout-ui
```
When `main` moves, or you amend `feat/api`, rebase the chain in order, parents first:
```bash
wtm sync --all --dry-run # the plan, nothing changed
wtm sync feat/api feat/api-client feat/checkout-ui
wtm sync --all --yes --push # unattended, then force-push with lease
```
On a conflict, `sync` aborts that branch's rebase and skips its descendants; `--keep-conflict` leaves the rebase in progress to resolve by hand. `--yes` never pushes unless `--push` is given.
When `feat/api` is merged (squash or rebase merges included), move its child onto `main` and remove it:
```bash
wtm reparent feat/api-client --to main --yes
wtm sync feat/api-client feat/checkout-ui --yes --push
wtm clean feat/api --yes
```
Or in one pass once several PRs are merged, with `gh` installed: `wtm prune --merged --reparent-children --yes`. `wtm tree --output mermaid` prints the stack as a flowchart for a PR description.
`prune` only reads the branches that have a worktree: it asks GitHub for their pull requests in one query, however old the pull request, and its fetch refreshes only their remote-tracking refs, so a repository with thousands of branches costs it no more than one with ten. The remote-tracking refs of the other branches are left as they are: `git fetch --prune` refreshes them.
## Run a command across worktrees
[Section titled “Run a command across worktrees”](#run-a-command-across-worktrees)
Several branches in flight, one lockfile bump or one test suite to run on all of them. Create them in one go, run the command everywhere in parallel, remove them in one go:
```console
$ wtm create feat/login feat/billing fix/header --yes
$ wtm exec --all -- 'pnpm lint && pnpm test'
✓ main (41.2s)
✗ feat/billing (exit 1, 38.7s)
✓ feat/login (40.1s)
✓ fix/header (39.5s)
✗ pnpm lint && pnpm test · 4 worktrees: 1 failed
✗ feat/billing (exit 1, 38.7s)
FAIL src/invoice.test.ts > rounds the total
log /code/acme/.git/wtm/exec/feat%2Fbilling.log
✓ 3 passed
$ wtm clean feat/login fix/header --yes
```
* Everything after `--` is one `/bin/sh -c` line, run from each worktree's root: quote it when it holds `&&` or a pipe, or your own shell takes them.
* Each command gets **its own worktree's** environment: the variables your shell carries about the worktree you stand in are removed, and the target's run variables (compose project, shifted ports) are added when it has them, as for its hooks. See [The environment of `wtm exec`](/guide/configuration/#the-environment-of-wtm-exec).
* Name worktrees (`wtm exec feat/login feat/billing -- pnpm test`), or `--all` for every one, the main checkout included. Without either, `wtm exec` opens a wizard that also asks for the command.
* `--jobs N` caps how many run at once (one per CPU by default): `wtm exec --all --jobs 2 -- pnpm install`. stdin is closed, so an interactive command cannot wait for input.
* Successes show one line; failures show the tail of their output. `--print` shows every worktree's full output: `wtm exec --all --yes --print -- git log -1 --oneline`. Each worktree's whole output is kept in `.git/wtm/exec/.log`.
* The run exits `1` when any command failed; each worktree's own exit code is in the report, and in `--output json` (`{command, results: [{branch, path, status, exit_code, …}], failed: [branch…]}`) for a script or an agent.
`create` and `clean` with several branches go one after the other, and a failure does not stop the others: the run ends with what succeeded and what failed, and exits with the first failure's code. Under `--yes`, `clean` refuses the whole batch before removing anything when one worktree is unsafe (dirty, unpushed, open PR, locked): pass `--force`, or name only the safe ones. Their `--output json` is an envelope, `{"results": [...], "failed": [...]}` (see [Migrating to 0.29](/guide/migrating-to-029/)).
# run.toml reference
`/wtm/run.toml` (`.git/wtm/run.toml` in a normal clone) declares the jobs of the `run` module. It is per-clone and never committed; share it with `wtm run export | wtm run import -`. `wtm run init` writes it from detection, `wtm run job …` / `wtm run profile …` edit it, and a hand edit is fine: the file is validated every time it is read, and every write puts its JSON schema beside it (`schemas/run.schema.json`) for editor autocomplete.
**Validation is strict.** An unknown key, a misspelt `kind`, a reference to an undeclared job, or two links claiming the same key refuse the file, naming the cause. A refused file never fails a core command (`create`, `extract`, `checkout`, `env`, `clean`…): only the run part is skipped, with a warning. `run up` and `run start` refuse it; `run down`, `run stop` and `run ps` warn and carry on, so what is running can always be stopped.
## Example
[Section titled “Example”](#example)
```toml
isolation = "isolated"
addressing = "names"
concurrency = "parallel"
[[job]]
name = "docker"
kind = "service"
cmd = "docker compose up -d"
stop = "docker compose down"
[job.ports]
DB_PORT = 5432
[[job]]
name = "api"
kind = "service"
cmd = "pnpm dev"
cwd = "apps/api"
[job.ports]
PORT = 4000
[job.url]
port = "PORT"
[[job]]
name = "migrate"
kind = "task"
cmd = "pnpm migrate"
touches = ["docker"]
[[profile]]
name = "all"
jobs = ["docker", "migrate", "api"]
default = true
[[env_port]]
file = "apps/api/.env"
key = "DATABASE_URL"
job = "docker"
port = "DB_PORT"
```
## Top-level keys
[Section titled “Top-level keys”](#top-level-keys)
| Key | Default | Meaning |
| -------------------- | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `isolation` | `"isolated"` | what a new worktree gets when nobody is asked: `"isolated"` or `"verbatim"`, see [Isolation](/guide/isolation/) |
| `addressing` | `"names"` | what an `[[env_port]]` link writes into a value pointing at a job that publishes a URL: its named origin (`"names"`) or its port (`"ports"`), see [Addressing](/guide/addressing/) |
| `concurrency` | unset (asked once) | the standing answer when another worktree runs jobs: `"parallel"` keeps them, `"exclusive"` stops them first |
| `port_offset_block` | `10` | the spacing between two worktrees' ports: worktree `n` binds `base + n × block` |
| `port_probe_timeout` | `15` | seconds `run up` / `run start` wait for a declared port to answer; a negative value turns the check off |
## `[[job]]`
[Section titled “\[\[job\]\]”](#job)
| Key | Required | Meaning |
| --------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| `name` | yes | unique, no spaces |
| `kind` | yes | `"service"` (long-running) or `"task"` (one-shot) |
| `cmd` | yes | a `/bin/sh` line |
| `stop` | no | services only: a `/bin/sh` line bringing the service down. Its presence makes `cmd` a launcher wtm waits on (`docker compose up -d`) |
| `cwd` | no | working directory, relative to the worktree root |
| `ports` | no | `NAME = base` pairs: the job runs with `NAME=base + offset` in its environment |
| `url` | no | `{ port = "NAME", host = "api" }`: publish the declared port `NAME` under a named URL; `host` defaults to the job's name |
| `probe` | no | `false` skips the port check for this job |
| `binds_no_port` | no | `true` for a service that listens on nothing by design (a watcher, a worker, a runner) |
| `runs` | no | the declared jobs this one starts itself (`turbo run dev`); no cycles |
| `touches` | no | the declared services whose data this job changes, see [foreign data](/guide/isolation/#foreign-data-and-touches) |
| `scope` | no | `"shared"`: one instance for the repository, run in the main checkout, see [Shared services](/guide/shared-services/). Absent means one per worktree |
| `namespace` | no | shared services only: `{ name, create, remove, env }`, the worktree's own part of the service. `name` and `create` are required together |
A job runs with the worktree's identity in its environment: `WTM_BRANCH`, `WTM_WORKTREE` (the branch as a slug), `WTM_ORDINAL` (the main checkout is `0`), `WTM_PORT_OFFSET`, `WTM_ISOLATION`, `COMPOSE_PROJECT_NAME` (isolated worktrees and the main checkout) and its declared ports. Two base ports a multiple of `port_offset_block` apart are refused, since two worktrees would meet on the same port.
## `[[profile]]`
[Section titled “\[\[profile\]\]”](#profile)
| Key | Required | Meaning |
| --------- | -------- | ----------------------------------------------------------------------- |
| `name` | yes | unique, no spaces |
| `jobs` | yes | declared job names, in start order |
| `default` | no | `true` on at most one profile: what `run up` starts without `--profile` |
## `[[env_port]]`
[Section titled “\[\[env_port\]\]”](#env_port)
Links a `.env` key to a declared port, so the port inside its value follows the worktree:
| Key | Meaning |
| ------ | ----------------------------------------------------------------------------- |
| `file` | a `.env` target configured in `config.toml` |
| `key` | the key whose value carries the port |
| `job` | the job declaring the port, required since two jobs may both declare a `PORT` |
| `port` | the declared port name |
wtm finds the declared base inside the value and shifts only that number or, under `addressing = "names"`, writes the job's whole named origin when the value is a URL and the job publishes one. A value where the base is missing or appears twice is reported and left alone.
## `[[env]]`
[Section titled “\[\[env\]\]”](#env)
Writes a `.env` key's whole value from a template: what a worktree holds of a shared service, which no port can say:
| Key | Meaning |
| ------- | ----------------------------------------------------------------------------------- |
| `file` | a `.env` target configured in `config.toml` |
| `key` | the key wtm owns |
| `job` | the job the value speaks about |
| `value` | a template over `{namespace}`, `{port.NAME}`, `{origin}`, `{worktree}`, `{ordinal}` |
A key is written by an `[[env]]` link or an `[[env_port]]` link, never both.
A link, of either table, whose `file` is not a `.env` target configured in `config.toml` is ignored with a warning: `create`, `checkout` and `wtm env` still write every other link, and name the ignored one (`env_port KEY in FILE ignored: not a configured env file…`) in their output and in the JSON `warnings`. Add the file to `[env]`, or delete the link.
# Shared services and namespaces
Isolation duplicates everything: two worktrees of a project with four postgres containers and a keycloak run eight postgres and two JVMs. A **shared service** runs once for the whole repository instead, and gives each worktree its own **namespace** in it: a database, a realm.
## Declaring one
[Section titled “Declaring one”](#declaring-one)
```toml
[[job]]
name = "postgres"
kind = "service"
cmd = "docker compose up -d postgres"
stop = "docker compose stop postgres"
scope = "shared"
[job.ports]
POSTGRES_PORT = 5432
[job.namespace]
name = "app_{worktree}" # app_feat-x in worktree feat/x
create = "scripts/db-worktree-add.sh" # run on every start: must be safe to run again
remove = "scripts/db-worktree-drop.sh" # run by wtm clean and wtm prune
```
`wtm run init` asks which compose services to share, then their namespace, row by row; `wtm run job add|edit` take the same fields as flags (`--scope shared`, `--namespace-name`, `--namespace-create`, `--namespace-remove`, `--namespace-env KEY=VALUE`).
Without a `[job.namespace]` the service is **shared outright**, data included: every worktree reads and writes the same data, which is [foreign data](/guide/isolation/#foreign-data-and-touches) to all of them.
## Where it runs
[Section titled “Where it runs”](#where-it-runs)
The real service runs in the **main checkout**, which never takes a port offset: a declared `5432` is the `5432` it binds, in every worktree. A worktree that starts it (`run up` of a profile holding it, or `run start`) starts it in the main checkout if it is not up yet, then holds it: `run ps` shows that hold as `joined`. The service stops only once no worktree holds it any more; `run down` in one worktree reports `released` for a service others still hold.
## The namespace commands
[Section titled “The namespace commands”](#the-namespace-commands)
wtm does not know what a database or a realm is: it runs your two commands at the right moment, with the right environment.
* `name` is data, never executed: wtm fills in `{worktree}` (the branch as a slug) and `{ordinal}`. `app_{worktree}` is the proposal.
* `create` runs on **every** start of the shared service, retried for a short while in case the service is not accepting connections yet. It must create the namespace if it is absent and do nothing if it is there.
* `remove` runs when the worktree is removed, never on `run stop` or `run down`. Leave it empty to keep the data.
Both are `/bin/sh` lines (or a script path) that read `$WTM_NAMESPACE`, `$WTM_WORKTREE`, `$WTM_ORDINAL`, the worktree's declared ports and URLs, and the extra variables of `namespace.env`. A `create` that clones main's database (`CREATE DATABASE "$WTM_NAMESPACE" TEMPLATE app`) starts each worktree from main's data without sharing it; see [the developer notes](/dev/shared-services/#starting-a-namespace-from-mains-data) for a complete script.
A worktree records each namespace it actually created in its `meta.json` (`namespaces`), the moment the service reports started. A worktree created and thrown away without ever starting the service owes nothing.
## Telling the app: `[[env]]`
[Section titled “Telling the app: \[\[env\]\]”](#telling-the-app-env)
A port link (`[[env_port]]`) says where the shared service answers: the same address for everyone. Which namespace a worktree holds is said by an `[[env]]` link, which writes a key's **whole** value from a template:
```toml
[[env]]
file = "apps/api/.env"
key = "DATABASE_URL"
job = "postgres"
value = "postgresql://app:app@localhost:{port.POSTGRES_PORT}/{namespace}"
```
The placeholders are `{namespace}`, `{port.NAME}` (a port of that job, as it resolves in the worktree), `{origin}` (the job's published address), `{worktree}` and `{ordinal}`; anything else is refused when `run.toml` is read. A key may be written by an `[[env]]` link or an `[[env_port]]` link, never both. The links are settled when a worktree is created and whenever `wtm env` reconciles it, no daemon needed. The main checkout gets none: its databases and realms are its own, so its `.env` keeps the values it has, and `wtm env main` puts back the template's value of any key an earlier wtm wrote there (`wt_main`, `acme-main`).
## What `clean` and `prune` do with the data
[Section titled “What clean and prune do with the data”](#what-clean-and-prune-do-with-the-data)
Removing a worktree runs in a fixed order: its jobs are stopped and checked gone, its `on_clean` hooks run, git removes the worktree, and **only then** is its data dropped. A failure before that last step leaves the data where it was.
* **By default the namespaces are dropped**: the confirmation names each one, and `--output json` reports each as `dropped`, `deferred` or `kept`.
* `--keep-data` withholds the drop.
* A shared service that is down cannot drop anything. The interactive form asks whether to start it now or keep the data until wtm next starts it; `--yes` keeps it (`deferred`), `--drop-data` starts the service, drops, and lets it go again.
* A deferred drop is recorded in `pending-removals.toml` and paid the next time wtm starts that service (`run up`, `run start`) or runs `prune`. A drop that takes longer than 30 s is deferred the same way. If a worktree of the same name is created again before then, the debt is withdrawn, never paid.
* A namespace another live worktree reaches under the same name is never dropped (`kept`).
# Where wtm keeps its state
## Per repository: `/wtm/`
[Section titled “Per repository: \/wtm/”](#per-repository-git-common-dirwtm)
Everything wtm knows about a repository lives under its git common directory (`.git/wtm/` in a normal clone). Git never commits anything inside `.git/`, so none of it reaches your teammates or `git status`.
```plaintext
/wtm/
├── config.toml # project settings (wtm init)
├── run.toml # jobs and profiles (wtm run init), optional
├── schemas/ # JSON schemas, rewritten beside each file on every write
├── worktrees//
│ └── meta.json # one per worktree wtm created or adopted
├── logs//.log # each job's output, cleared when the job starts
├── hooks/-.log # the raw output of the last on_create / on_clean run
├── exec/.log # the whole output of the last wtm exec in that worktree
├── pending-removals.toml # namespace drops owed by a clean while their service was down
└── ordinal.lock # serialises the allocation of worktree numbers
```
`` is the branch name URL-escaped into one path segment (`feat/x` → `feat%2Fx`).
### `meta.json`
[Section titled “meta.json”](#metajson)
```console
$ cat '.git/wtm/worktrees/feat%2Flogin/meta.json'
{
"source_branch": "main",
"created_at": "2026-10-04T16:52:28Z",
"env_strategy": "example",
"isolation": "isolated"
}
```
| Field | Meaning |
| --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `source_branch` | the parent `wtm sync` rebases onto |
| `created_at` | creation time |
| `env_strategy` | how its `.env` files were provisioned: `example`, `main` or `parent` |
| `ordinal` | its stable number, from which its ports are derived (`base + ordinal × port_offset_block`). Allocated on first need and released when the worktree is cleaned; the main checkout is `0` and has no `meta.json` |
| `isolation` | `isolated` or `verbatim`. Absent on a worktree created before v0.28, whose [adoption](/guide/isolation/#worktrees-created-before-v028) is pending |
| `namespaces` | the shared services it created a namespace in, which `clean` and `prune` drop |
### `pending-removals.toml`
[Section titled “pending-removals.toml”](#pending-removalstoml)
A `clean` or `prune` that could not drop a namespace (its shared service was down, the drop timed out, or was kept under `--yes`) records the debt here. It is paid the next time wtm starts that service or runs `prune`, and withdrawn if a worktree of the same name is created again first. See [Shared services](/guide/shared-services/#what-clean-and-prune-do-with-the-data).
## Per machine: beside the global config
[Section titled “Per machine: beside the global config”](#per-machine-beside-the-global-config)
The global config lives in the OS config directory (`~/.config/wtm/` on Linux, `~/Library/Application Support/wtm/` on macOS), and the run daemon keeps its files next to it:
```plaintext
/wtm/
├── config.toml # your personal settings: shell, [ui], [proxy]
├── state.json # what wtm writes for itself (the update check)
├── wtm.sock # the run daemon's socket, shared by every repository
├── wtm.lock # held by the one daemon running
├── jobs.json # the daemon's index of what it started
├── repos.json # every repository wtm was used in, for `wtm events` run outside one
└── repos.json.lock
```
`jobs.json` is what makes the daemon disposable: it exits about 30 s after its last foreground job, detached services keep running without it, and the next daemon reads the index back, so `wtm run ps` still lists a compose stack after a reboot and `wtm run down` still stops it. `wtm run daemon status` reports what is up; `wtm run daemon restart` replaces a daemon of another wtm build.
On macOS, `wtm run proxy install` adds one file of its own: a LaunchAgent under `~/Library/LaunchAgents`, removed by `wtm run proxy uninstall`.
# Troubleshooting
Common problems, what causes them, and the command that fixes each one.
* [A port is already in use](#a-port-is-already-in-use)
* [A job is reported crashed](#a-job-is-reported-crashed)
* [The daemon is another version](#the-daemon-is-another-version)
* [A stack started by 0.27 is still running](#a-stack-started-by-027-is-still-running)
* [`wtm go` does not change directory](#wtm-go-does-not-change-directory)
* [No `run.toml` (exit 16)](#no-runtoml-exit-16)
* [A named URL does not answer](#a-named-url-does-not-answer)
* [A `.env` is out of date](#a-env-is-out-of-date)
## A port is already in use
[Section titled “A port is already in use”](#a-port-is-already-in-use)
**Symptom.** `wtm run up` reports a job started, then `wtm run ps` shows it `crashed`, and its log ends with `Address already in use` (or `EADDRINUSE`).
**Cause.** Something else listens on the port the worktree was given. Find it:
```bash
wtm run ps # another worktree's job?
lsof -nP -iTCP:5183 -sTCP:LISTEN # any other process
```
**Fix**, depending on what holds it:
* **An app you started by hand** (often on the base port, in the main checkout): stop it, or let wtm run it with `wtm run start --job `.
* **Another worktree's job**: every isolated worktree has its own ports, so this is usually a **verbatim** worktree and its source, which share their ports on purpose. `run up` offers to stop the other one; under `--yes`, pass `--exclusive`. To run both at once, give the worktree its own ports: `wtm env --isolation isolated`.
* **A port in another project** that happens to fall on one of yours: move this project's ports with `port_offset_block` at the top of `run.toml`, or change the base port with `wtm run job edit --port PORT= --yes`, then `wtm env ` to settle each worktree's `.env`.
**A related warning: "Ports declared but not bound".** Nothing answers on the port wtm gave the job, often because something answers on the base port instead. The command never read its variable: pass it explicitly (`--cmd 'pnpm dev --port ${PORT}'`), check that the app's `.env` does not pin a port, and in a Turborepo let the variable through (`globalPassThroughEnv` in `turbo.json`). `probe = false` on a job silences the check. See [Checking the ports](/guide/jobs-and-profiles/#checking-the-ports).
## A job is reported crashed
[Section titled “A job is reported crashed”](#a-job-is-reported-crashed)
**Symptom.** `wtm run ps` lists a job as `crashed`, or `run up` says it exited right after starting.
**Fix.** Read its output. The log is kept after the process is gone:
```bash
wtm run logs --job api # the run view, focused on api
wtm run logs feat/login --output json # the last 1000 lines of each job
```
The raw file is `.git/wtm/logs//.log`, cleared each time the job starts. Once the cause is fixed, `wtm run start --job api` starts it again; `wtm run ps --output json` gives the exit code (`exit_code`).
Frequent causes: a port in use (above), dependencies not installed in the new worktree (add `pnpm install` to the `on_create` hooks with `wtm init --only hooks`), or a command that only works from another directory (set the job's `cwd`).
## The daemon is another version
[Section titled “The daemon is another version”](#the-daemon-is-another-version)
**Symptom.** After an upgrade, a `wtm run` command stops with a message naming two versions: the daemon holding the socket, and this wtm.
**Cause.** Jobs are run by one background daemon shared by every repository, and it outlives the command that started it. A daemon from the previous binary keeps its own behavior until it is replaced. One holding no job is replaced automatically.
**Fix.**
```bash
wtm run daemon status # which build is running, and what it holds
wtm run daemon restart # hand the jobs over to this binary's daemon
```
Detached services (the ones with a `stop` command, such as a compose stack) survive the restart; foreground ones are stopped, so start them again with `wtm run up`.
## A stack started by 0.27 is still running
[Section titled “A stack started by 0.27 is still running”](#a-stack-started-by-027-is-still-running)
**Symptom.** After upgrading from 0.27, `wtm run up` starts a second copy of a compose stack, or fails on a port a running container holds, while `wtm run ps` shows nothing for it.
**Cause.** Before 0.28, jobs ran without `COMPOSE_PROJECT_NAME`, so compose named each stack after its directory (`feat-login`). wtm now names it `-` (`acme-feat-login`) and does not see the old one.
**Fix.** Stop each old stack once, by its old name:
```bash
docker compose ls # find the projects named after a directory
docker compose -p feat-login down # no -v: the volumes stay
```
The new stack uses new volumes (`acme-feat-login_*`), so it starts empty. A worktree created by 0.27 is also refused by `run up` until you decide its isolation: `wtm env --isolation isolated` (its own ports and project) or `--isolation verbatim` (keep its source's). See [Migrating to 0.28](/guide/migrating-to-028/).
## `wtm go` does not change directory
[Section titled “wtm go does not change directory”](#wtm-go-does-not-change-directory)
**Symptom.** `wtm go feat/login` prints `wtm go requires shell integration to change your working directory`, or prints nothing and you stay where you were.
**Cause.** A program cannot change its parent shell's directory; the shell function from `wtm shell-init` does it for it. It is missing from this shell.
**Fix.** Add it to your shell's startup file and open a new shell:
```bash
echo 'eval "$(wtm shell-init)"' >> ~/.zshrc # ~/.bashrc for bash
```
For fish, add `wtm shell-init | source` to `config.fish`. After an upgrade, open a new shell so the function matches the binary. In a script, where no startup file is read, use `cd "$(wtm resolve feat/login)"` instead.
## No `run.toml` (exit 16)
[Section titled “No run.toml (exit 16)”](#no-runtoml-exit-16)
**Symptom.** `wtm run up` (or `start`, `list`, `url`…) fails with `no run.toml` and exit code `16`.
**Cause.** The `run` module is opt-in, and `run.toml` lives in `.git/wtm/`: it is per clone and never committed. A new clone has none, even when a teammate's does. Every worktree of a clone shares the same file.
**Fix.** Create it from detection, or copy it from a clone that has one:
```bash
wtm run init # detect compose files and scripts
wtm run export > run.json # in the clone that has it
wtm run import run.json # in this one
```
After an import, `wtm env ` settles each worktree's `.env` on it.
## A named URL does not answer
[Section titled “A named URL does not answer”](#a-named-url-does-not-answer)
**Symptom.** `http://api.feat-login.acme.localhost:11080` does not load, while the job looks up.
Go through these in order:
1. **Is the job run by wtm?** Named URLs are served by the run proxy while `wtm run` runs the job. An app started by hand has none: use its port URL, printed by `wtm run url --job api --raw`. `wtm run ps` shows what wtm runs.
2. **Does the proxy listen?** `wtm run proxy status` prints the port it binds and the one URLs carry. When another program holds the port, the names are lost but the jobs still run: set another port in the global config (its path is in the status output) and `wtm run daemon restart`.
```toml
[proxy]
port = 11090
```
3. **Does the client resolve `*.localhost`?** Browsers and curl send `*.localhost` to the loopback; some other HTTP clients and tools do not. Use `wtm run url --raw` for those.
4. **Is the URL still current?** A branch's host is its slug (`feat/login` becomes `feat-login`). `wtm run url feat/login --job api` prints the exact one.
Without the port: on macOS, `wtm run proxy install` serves the names on port 80, then `wtm env ` drops the port from the `.env` values. To use port URLs everywhere, `wtm run addressing ports`. See [Named URLs](/guide/addressing/).
## A `.env` is out of date
[Section titled “A .env is out of date”](#a-env-is-out-of-date)
**Symptom.** A worktree's `.env` misses a key the template gained, still carries a value from before, or points at the wrong port after `run.toml` changed.
**Fix.** Compare it with its source, then reconcile:
```bash
wtm env feat/login --check # read-only report; exits 18 on drift
wtm env feat/login # add missing keys, settle the ports
wtm env feat/login --mode refresh --on-conflict overwrite --yes # also overwrite diverging values
wtm env feat/login --prune --yes # drop keys no source has any more
```
The values come from the strategy the worktree was created with (`example`, `main` or `parent`); `--from` overrides it for one run. The ports and addresses `run.toml` links are settled on the worktree's own at the same time. `wtm env main` does the same for the main checkout, keeping the addressing its `.env` spells unless `--addressing` says otherwise.
The report prints only the values wtm writes itself: the linked ports and addresses (with a URL's password masked), `COMPOSE_PROJECT_NAME` and the `[[env]]` values. Every other value, secrets included, is withheld, so the report and its `--output json` are safe to paste into a log or an agent's context; a conflict reads "local value differs from main". `--show-values` prints them all.
# Changelog
All notable changes to wtm are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and wtm adheres to [Semantic Versioning](https://semver.org); how to write an entry is in [docs/dev/changelog.md](/dev/changelog/).
## [Unreleased](https://github.com/LucasPcq/wtm/compare/v0.29.2...HEAD)
[Section titled “Unreleased”](#unreleased)
## [0.29.2](https://github.com/LucasPcq/wtm/releases/tag/v0.29.2) - 2026-10-06
[Section titled “0.29.2 - 2026-10-06”](#0292---2026-10-06)
For scripts and agents: `wtm events` follows every repository and reports job crashes, `wtm env` stops printing secrets, `wtm prune` stays fast on repositories with many branches, and usage errors exit `2`.
### Added
[Section titled “Added”](#added)
* **`wtm events --all`** follows every repository wtm knows from any directory, ignoring an inherited `GIT_DIR`; integrations should pass it instead of running from outside a repository. → [Every repository at once](/guide/events/#every-repository-at-once)
* **`wtm env --addressing ports|names`** moves the main checkout's addresses onto names, or back to ports, on its own. → [The main checkout](/guide/addressing/#the-main-checkout)
* **`wtm events`** reports jobs starting, crashing, exiting and stopping (`job.*`), shared services naming the worktrees that hold them, and its snapshot lists each worktree's jobs; same schema `v: 1`. → [Jobs](/guide/events/#jobs)
### Changed
[Section titled “Changed”](#changed)
* **`wtm env`** prints only the values wtm writes, in text and JSON (`"redacted": true` for the others), and masks URL passwords, also in `env_ports`; `--show-values` prints everything. → [A .env is out of date](/guide/troubleshooting/#a-env-is-out-of-date)
* **`wtm env main`** keeps the addressing its `.env` spells: reconciling its keys no longer moves it onto named URLs; pass `--addressing names` for that. → [The main checkout](/guide/addressing/#the-main-checkout)
* **`wtm prune`** reads only the branches that have a worktree, on git and on GitHub, so its time no longer grows with the repository's branches; it no longer refreshes the other remote-tracking refs. → [Stacked pull requests](/guide/recipes/#stacked-pull-requests)
* **`wtm env`** asks whether to keep or switch the worktree's isolation (the main checkout: its addressing), keeping it by default; the recap's verbatim action is gone. → [Isolation](/guide/isolation/#changing-your-mind)
* **`--output json` without `--yes`** on a command that could ask (`create`, `checkout`, `sync`, `clean`, `env`…) exits `2`, a usage error, instead of `1`: a script branching on `1` should read `2`. → [Exit codes](/guide/integrations/#the-contract---yes-and---output-json)
### Fixed
[Section titled “Fixed”](#fixed)
* **`wtm prune`** and **`wtm tree --with-prs`** find a worktree's pull request however many newer ones the repository has, instead of only among the 100 newest. → [Stacked pull requests](/guide/recipes/#stacked-pull-requests)
* **`wtm env main`** no longer writes `[[env]]` namespaces into the main checkout (`wt_main`, `acme-main`), and puts back the template's value where an earlier run did. → [Shared services](/guide/shared-services/#telling-the-app-env)
* **Usage errors exit `2`** instead of `1`: flags that cannot be combined (`clean`/`prune --keep-data --drop-data`, `run up --exclusive --parallel`, `sync --push --no-push`…), `--all` with a name, and `wtm checkout `, now refused before the config is read. → [Exit codes](/guide/integrations/#the-contract---yes-and---output-json)
* **`wtm run list`** in a terminal opens its picker again instead of printing `Aborted.` straight away.
* **`wtm env`**'s resolver shows a kept conflict as your value alone, instead of an arrow from your value to itself.
* **`wtm prune --dry-run --output json`** reports `"dry_run": true` when there is nothing to prune.
## [0.29.1](https://github.com/LucasPcq/wtm/releases/tag/v0.29.1) - 2026-10-05
[Section titled “0.29.1 - 2026-10-05”](#0291---2026-10-05)
A fix release for `wtm create` and `run.toml`, with a shorter isolation question.
### Changed
[Section titled “Changed”](#changed-1)
* **`wtm create ...`** with several names skips the branches step, as a single name does, and opens the wizard on the source branch.
* **`wtm create`** and **`wtm checkout`** ask the isolation question in one line, the detail is in the guide. → [Isolation](/guide/isolation/)
### Fixed
[Section titled “Fixed”](#fixed-1)
* **`run.toml`**: a link to a `.env` that `config.toml` does not configure is ignored with a warning, and no longer stops the other `.env` values from being written. → [run.toml](/guide/run-toml/#env)
* **`wtm create`** keeps the names you are typing when the branch fetch finishes, and a refreshed branch or worktree picker keeps its highlighted row.
## [0.29.0](https://github.com/LucasPcq/wtm/releases/tag/v0.29.0) - 2026-10-04
[Section titled “0.29.0 - 2026-10-04”](#0290---2026-10-04)
wtm opens up to other tools with a live event stream, and works on several worktrees at once.
### Highlights
[Section titled “Highlights”](#highlights)
* **`wtm events`** streams every worktree change as it happens, as JSON Lines for editors, terminal plugins and agents. → [Event stream](/guide/events/)
* **`wtm create`** and **`wtm clean`** take several worktrees in one run: `wtm create feat/a feat/b`. → [Recipes](/guide/recipes/#run-a-command-across-worktrees)
* **`wtm exec`** runs one command in several worktrees in parallel, each with its own ports: `wtm exec --all -- pnpm test`. → [Recipes](/guide/recipes/#run-a-command-across-worktrees)
### Breaking
[Section titled “Breaking”](#breaking)
* **`wtm create --output json`** and **`wtm clean --output json`** answer with an envelope: read `.results[0]`. → [Migrating to 0.29](/guide/migrating-to-029/)
* **Exit code `21`** for any command run outside a git repository (was `1`). → [Migrating to 0.29](/guide/migrating-to-029/)
* **Exit code `19`** for an interactive cancellation (was `0`), so `wtm create x && wtm go x` stops there. → [Migrating to 0.29](/guide/migrating-to-029/)
### Added
[Section titled “Added”](#added-1)
* **`wtm events`** outside a repository follows every repository wtm has been used in. → [Every repository at once](/guide/events/#every-repository-at-once)
* **`worktree.provisioned`** and **`worktree.deprovisioned`** events tell when a worktree's hooks have run, and whether they passed.
* **`WTM_CORRELATION_ID`** tags the events a command publishes, so a tool recognises its own. → [Integrations](/guide/integrations/)
* **`wtm version --output json`** reports the version of each machine contract, for integrations to check compatibility.
* **Locked worktrees** (`git worktree lock`) are refused by `clean` and `prune` unless `--force`, and marked in `list` and `tree`.
* **`wtm ui`** creates and deletes several worktrees at once, and picks up changes made elsewhere immediately.
### Changed
[Section titled “Changed”](#changed-2)
* **`wtm env --check`** exits `18` on drift, ready for CI.
* **`wtm checkout`** and **`wtm extract`** ask the same questions as `create` and always show a recap before acting.
* **`wtm env`** reports a count per file and only the keys left to handle; warnings go to stderr.
* **Invalid flag values** and branch names git would reject are refused upfront with exit `2`, before anything is created.
### Fixed
[Section titled “Fixed”](#fixed-2)
* **`wtm relocate --to`** rewrites `base_path` even when no worktree has to move.
* **`wtm create`** and **`wtm checkout`** reject an unknown `--env-from` before creating a half-provisioned worktree.
* **A branch can no longer be its own parent** (`create b --from b`).
* **`wtm extract`** no longer ignores `--from` and `--ff` when the target is picked in the wizard.
## [0.28.0](https://github.com/LucasPcq/wtm/releases/tag/v0.28.0) - 2026-09-30
[Section titled “0.28.0 - 2026-09-30”](#0280---2026-09-30)
Each worktree runs its own services on its own ports; read the [migration guide](/guide/migrating-to-028/) if you used `wtm run`, `wtm switch` or script wtm.
### Highlights
[Section titled “Highlights”](#highlights-1)
* **The `run` module**: per-worktree services and tasks grouped in profiles; `wtm run init` writes the config, `wtm run up` starts the stack. → [Jobs and profiles](/guide/jobs-and-profiles/)
* **Per-worktree isolation**: shifted ports (`3000` → `3010`) rewritten in `.env`, and its own `COMPOSE_PROJECT_NAME`; `wtm env` settles them. → [Isolation](/guide/isolation/)
* **Named URLs** per job and worktree (`http://web.feat-login.acme.localhost:11080`), on port 80 on macOS with `wtm run proxy install`. → [Addressing](/guide/addressing/)
### Breaking
[Section titled “Breaking”](#breaking-1)
* **`wtm switch`** is removed: use `wtm go` then `wtm run up`. → [Migrating to 0.28](/guide/migrating-to-028/)
* **`--non-interactive`** is removed: use `--yes`. → [Migrating to 0.28](/guide/migrating-to-028/)
* **Hooks** run through `/bin/sh -c` with placeholders already quoted: drop your own quotes around `{{worktree}}`. → [Migrating to 0.28](/guide/migrating-to-028/#hooks)
* **`run` commands** take the worktree as argument and the job or profile as a flag, and their JSON changes shape: update scripts. → [Migrating to 0.28](/guide/migrating-to-028/#commands)
* **`run down --all`** stays in the current repository, **`run import`** replaces instead of merging, and `run.toml` is validated more strictly. → [Migrating to 0.28](/guide/migrating-to-028/#commands)
### Added
[Section titled “Added”](#added-2)
* **Shared services**: one postgres for the repository, one database per worktree, dropped on `clean`. → [Shared services](/guide/shared-services/)
* **`run up`** stops before a migration touches data the worktree does not own.
* **`wtm run up feat-a feat-b`** runs several worktrees at once, and **`wtm ui`** shows and drives the services.
### Changed
[Section titled “Changed”](#changed-3)
* **Output** is more consistent: a `┃` bar marks wtm's blocks, hooks sum up in one line, and `--quiet` works everywhere.
### Fixed
[Section titled “Fixed”](#fixed-3)
* **The `wtm` shell function** returns the command's exit code (open a new shell after upgrading).
* **`wtm init`** no longer installs every workspace package separately.
* **Rewriting a `.env` value** keeps its quotes, comment and line endings.
## [0.27.1](https://github.com/LucasPcq/wtm/releases/tag/v0.27.1) - 2026-09-09
[Section titled “0.27.1 - 2026-09-09”](#0271---2026-09-09)
A child worktree no longer pushes to its parent's branch.
### Fixed
[Section titled “Fixed”](#fixed-4)
* **`wtm create`** from a parent only on `origin` no longer makes it the child's upstream, so `git push` targets the child's branch.
* **`wtm prune --gone`** no longer offers to remove a never-pushed child whose parent's remote branch was deleted.
* **Not retroactive**: in an older child whose upstream names another branch, run `git branch --unset-upstream`, then `git push -u origin HEAD`.
## [0.27.0](https://github.com/LucasPcq/wtm/releases/tag/v0.27.0) - 2026-08-20
[Section titled “0.27.0 - 2026-08-20”](#0270---2026-08-20)
Self-update with `wtm upgrade`, and `wtm fast-forward`.
### Added
[Section titled “Added”](#added-3)
* **`wtm upgrade`** updates wtm the way it was installed, checksum verified; `--check` shows what is available, `--version` pins a release.
* **Update notice**: wtm mentions a newer version at the end of a command, without slowing it.
* **`wtm fast-forward`** moves a branch to `origin/` and refuses a diverged one (use `wtm sync`); also in the dashboard.
### Changed
[Section titled “Changed”](#changed-4)
* **`wtm ui` help overlay** reads like a reference: four sections, sized to the screen, scrollable.
### Fixed
[Section titled “Fixed”](#fixed-5)
* **`wtm sync`** interactive shows its recap instead of an empty picker.
## [0.26.1](https://github.com/LucasPcq/wtm/releases/tag/v0.26.1) - 2026-08-20
[Section titled “0.26.1 - 2026-08-20”](#0261---2026-08-20)
The `wtm ui` detail panel no longer flickers.
### Fixed
[Section titled “Fixed”](#fixed-6)
* **`wtm ui`** reloads the detail panel when the selection or its branch changes, and on `r`, instead of every three seconds.
## [0.26.0](https://github.com/LucasPcq/wtm/releases/tag/v0.26.0) - 2026-08-20
[Section titled “0.26.0 - 2026-08-20”](#0260---2026-08-20)
A full-screen dashboard, `wtm ui`, to drive worktrees.
### Highlights
[Section titled “Highlights”](#highlights-2)
* **`wtm ui`**: a full-screen dashboard running `create`, `clean`, `reparent`, `prune` and `sync`, with keyboard, mouse and help on `?`.
### Added
[Section titled “Added”](#added-4)
* **`wtm ui` Tree tab** shows the stacked branches and reparents from the dashboard.
* **`wtm ui` detail panel** shows the last commit, working tree state, children, `.env` drift, removal blockers and the PR with its CI checks.
* **`wtm ui`** tells a failing `gh` apart from "no PR", and opens the PR in the browser with `p`.
* **`wtm list`** and **`wtm resolve`** mark the current worktree `● active`.
* **`ui.animations = false`** in the global config turns off dashboard animations.
### Changed
[Section titled “Changed”](#changed-5)
* **New colour palette** for every command's output, still respecting `NO_COLOR`.
### Fixed
[Section titled “Fixed”](#fixed-7)
* **`wtm sync`** keeps the parent step visible and refreshes parents the cascade does not cover.
* **README** no longer documents an `agent` key that made every command fail.
## [0.25.0](https://github.com/LucasPcq/wtm/releases/tag/v0.25.0) - 2026-08-17
[Section titled “0.25.0 - 2026-08-17”](#0250---2026-08-17)
`create`, `checkout` and `extract` reuse an existing local branch.
### Breaking
[Section titled “Breaking”](#breaking-2)
* **`wtm create --yes`** and **`wtm extract --to --yes`** require `--from `: wtm cannot guess the parent.
### Added
[Section titled “Added”](#added-5)
* **`wtm checkout `** reuses an existing local branch instead of asking for `wtm clean`, and offers to fast-forward it.
* **`wtm create `** and **`wtm extract --to `** reuse the branch, announced in the recap.
* **JSON** of `create` and `checkout` reports whether the branch existed and how it stands against origin.
### Changed
[Section titled “Changed”](#changed-6)
* **A branch checked out in another worktree** exits `10` with a `wtm go ` hint; `--if-not-exists` returns that worktree.
## [0.24.1](https://github.com/LucasPcq/wtm/releases/tag/v0.24.1) - 2026-08-11
[Section titled “0.24.1 - 2026-08-11”](#0241---2026-08-11)
`wtm extract` handles untracked files properly.
### Added
[Section titled “Added”](#added-6)
* **`wtm extract --files`** accepts a directory and takes every change below it.
### Changed
[Section titled “Changed”](#changed-7)
* **`wtm extract`** removes directories left empty in the source.
* **`wtm extract --on-conflict resolve`** also covers untracked files already in the target; identical content is no longer a conflict.
### Fixed
[Section titled “Fixed”](#fixed-8)
* **`wtm extract`** lists each file of a new directory separately, so `--files newmod/x.go` works.
* **`wtm extract`** handles paths with spaces or non-ASCII characters, and staged renames.
## [0.24.0](https://github.com/LucasPcq/wtm/releases/tag/v0.24.0) - 2026-07-08
[Section titled “0.24.0 - 2026-07-08”](#0240---2026-07-08)
`wtm env` detects and resolves `.env` drift between worktrees.
### Added
[Section titled “Added”](#added-7)
* **`wtm env [branch]`** detects and resolves a worktree's `.env` conflicts, following the strategy chosen at creation.
## [0.23.0](https://github.com/LucasPcq/wtm/releases/tag/v0.23.0) - 2026-07-07
[Section titled “0.23.0 - 2026-07-07”](#0230---2026-07-07)
`on_clean` hooks, generalized `.env` detection and harmonized output.
### Breaking
[Section titled “Breaking”](#breaking-3)
* **`.env` config** moves from `copy_files` to `[[env.file]]` entries, with no automatic migration: run `wtm init --only env` or edit `config.toml`.
### Added
[Section titled “Added”](#added-8)
* **`[hooks] on_clean`** runs before `clean`/`prune` remove a worktree (e.g. `docker compose down`); a failure aborts unless `continue_on_error`.
* **`wtm init --clean-command`** and **`--skip-clean`** configure `on_clean` non-interactively.
* **`sudo rm -rf` fallback**: an interactive run offers it when `git worktree remove` hits root-owned files, never on a dangerous path.
* **`.env` detection** recognises `.env.dist`, `.env.sample` and other templates, and flags `.env.local` as local.
### Changed
[Section titled “Changed”](#changed-8)
* **The `example` strategy** copies the detected template instead of hard-coding `.env.example`.
* **Output** conventions are harmonized across commands; `wtm sync` shows a spinner while pushing.
## [0.22.0](https://github.com/LucasPcq/wtm/releases/tag/v0.22.0) - 2026-07-03
[Section titled “0.22.0 - 2026-07-03”](#0220---2026-07-03)
An opt-in `run` module and one `--yes`/`--force` model across commands.
### Highlights
[Section titled “Highlights”](#highlights-3)
* **`wtm run init`** sets up `run.toml` from detected docker-compose files and package scripts, without overwriting existing jobs.
* **`--yes` and `--force`** are separate: `--yes` answers every question with a flag or a safe default, `--force` only lifts safety refusals.
### Breaking
[Section titled “Breaking”](#breaking-4)
* **`--output json`** requires `--yes` on every mutating command, and a missing required selection errors naming its flag: pass both.
* **The `run` module** is opt-in: `wtm init` no longer configures services (`--skip-services`, `--only services` removed); use `wtm run init`.
### Added
[Section titled “Added”](#added-9)
* **`origin` divergence badges** (`origin ↑a ↓b`) in `list`, `tree`, pickers and JSON, read without fetching; `r` refreshes.
* **`--ff`** fast-forwards a source that is only behind, also on `create --from` and `extract`.
* **`wtm extract [source]`** picks the source first, so you can extract from anywhere.
* **`wtm reparent`** moves several worktrees onto one new parent in a single pass.
### Changed
[Section titled “Changed”](#changed-9)
* **Wizards** keep every confirmation inside, with a breadcrumb and Back; `clean`, `relocate` and `sync` each run as one wizard.
* **`wtm agents install`** updates an already installed skill.
* **`wtm init`** points out pre-existing worktrees and suggests `wtm relocate`.
### Fixed
[Section titled “Fixed”](#fixed-9)
* **`wtm prune`** reads merged/closed from the GitHub PR state instead of local commits; a branch without a PR is never tagged.
* **`wtm clean`** and **`wtm relocate`** without a terminal or `--yes` error out instead of starting a wizard.
* **`wtm clean --reparent-children`** is honoured in the wizard.
## [0.21.0](https://github.com/LucasPcq/wtm/releases/tag/v0.21.0) - 2026-07-01
[Section titled “0.21.0 - 2026-07-01”](#0210---2026-07-01)
`wtm prune` cleans up finished work in one pass, and every command speaks JSON.
### Added
[Section titled “Added”](#added-10)
* **`wtm prune`** removes every worktree whose work is done, reparenting surviving children onto their grandparent.
* **`--merged`**, **`--closed`** and **`--gone`** narrow `prune` to branches with no commit ahead, a merged or closed PR, or a deleted remote.
* **`wtm prune`** needs `--force` for a dirty, unpushed or open-PR worktree, and never touches main or the base branch.
* **`wtm prune --dry-run`** previews without changing anything.
* **`wtm sync --keep-conflict`** leaves a conflicting rebase in progress for manual resolution instead of aborting it.
* **`wtm sync`** detects a rebase already in progress and blocks its descendants.
### Changed
[Section titled “Changed”](#changed-10)
* **`--output json`** is available on every command, with a stable payload.
* **`wtm --help`** groups commands into sections.
* **Command reference** under `docs/` is generated from the CLI.
### Fixed
[Section titled “Fixed”](#fixed-10)
* **`wtm sync`** captures conflicting files before aborting, and finds the branch of a worktree stuck mid-rebase.
## [0.20.0](https://github.com/LucasPcq/wtm/releases/tag/v0.20.0) - 2026-07-01
[Section titled “0.20.0 - 2026-07-01”](#0200---2026-07-01)
Branch pickers show remote branches and divergence, and multi-select lists can be filtered.
### Added
[Section titled “Added”](#added-11)
* **`wtm create --from origin/x`** creates a worktree from a remote branch you never checked out.
* **`wtm reparent --to origin/x`** reparents onto a remote branch.
* **Branch pickers** list `origin` branches too, and tag drifted local ones with `↑2 ↓5`; `r` fetches again.
* **`wtm create`** offers to fast-forward a source branch behind `origin/`, and warns when it has diverged.
* **Multi-select lists** filter on `/`; `a` toggles all filtered items.
### Changed
[Section titled “Changed”](#changed-11)
* **Worktree lists** are redesigned: aligned badges, a tinted selected row, a status glyph.
* **`create`**, **`extract`** and **`checkout`** ask before the `parent` strategy copies `.env` from the main worktree.
## [0.19.0](https://github.com/LucasPcq/wtm/releases/tag/v0.19.0) - 2026-06-27
[Section titled “0.19.0 - 2026-06-27”](#0190---2026-06-27)
A stacked-branch workflow: see the tree, reparent a branch, and sync only what you pick.
### Breaking
[Section titled “Breaking”](#breaking-5)
* **`wtm sync`** no longer syncs everything by default: pass branch names, pick them interactively, or use `--all`.
### Added
[Section titled “Added”](#added-12)
* **`wtm tree`** shows the forest of worktrees, flagging a child that needs a rebase with `⚠ needs sync`.
* **`wtm tree --with-prs`** adds PR state; **`--output mermaid`** prints a flowchart to paste into a PR.
* **`wtm reparent --to `** changes a worktree's parent; the rebase happens on the next `wtm sync`.
* **`wtm clean`** offers to reparent the children it would orphan onto the grandparent, or `--reparent-children`.
* **`wtm sync`** without arguments opens a multi-select picker.
### Changed
[Section titled “Changed”](#changed-12)
* **`wtm sync`** always refreshes the base first, and exits `11` on an unknown branch.
* **Output** has the same spacing and loader across commands; no spinner or `\r` reaches a pipe.
### Fixed
[Section titled “Fixed”](#fixed-11)
* **A fast task's** first output is no longer lost.
## [0.18.0](https://github.com/LucasPcq/wtm/releases/tag/v0.18.0) - 2026-06-24
[Section titled “0.18.0 - 2026-06-24”](#0180---2026-06-24)
`wtm checkout` replaces the `pr` group.
### Breaking
[Section titled “Breaking”](#breaking-6)
* **`wtm pr checkout`** is now **`wtm checkout`**: update scripts and aliases.
* **`wtm pr list`** is removed: use `wtm list --with-prs` or the `checkout` wizard.
### Added
[Section titled “Added”](#added-13)
* **`wtm checkout [number]`** creates a worktree from a pull request; without a number, a wizard lists open PRs.
* **`--review`**, **`--mine`**, **`--from`** and **`--env-from`** filter PRs and preset the wizard's answers.
## [0.17.0](https://github.com/LucasPcq/wtm/releases/tag/v0.17.0) - 2026-06-24
[Section titled “0.17.0 - 2026-06-24”](#0170---2026-06-24)
Worktree commands move to the top level.
### Breaking
[Section titled “Breaking”](#breaking-7)
* **`wtm wt`** is removed: `wtm wt list` becomes `wtm list`, and so on; update scripts and re-run `eval "$(wtm shell-init)"`.
### Fixed
[Section titled “Fixed”](#fixed-12)
* **A detached job's output** is no longer truncated when its process exits.
## [0.16.0](https://github.com/LucasPcq/wtm/releases/tag/v0.16.0) - 2026-06-22
[Section titled “0.16.0 - 2026-06-22”](#0160---2026-06-22)
`wt relocate` gathers worktrees under `base_path`, and unused configuration is removed.
### Breaking
[Section titled “Breaking”](#breaking-8)
* **`wtm pr create`** is removed: use `gh pr create`.
* **`[agents]`, `[integrations]`, `[github]`** and the global `agent` key are refused: delete them or re-run `wtm init`.
### Added
[Section titled “Added”](#added-14)
* **`wtm wt relocate`** moves scattered worktrees under `base_path` and adopts external ones, with a preview; `--to` and `--output json` for scripts.
### Removed
[Section titled “Removed”](#removed)
* **The default-agent setting**: the `agent` key, the `--agent` flag and its `init` step.
* **`[github] auto_draft`**, unused since `pr create` was removed.
## [0.15.0](https://github.com/LucasPcq/wtm/releases/tag/v0.15.0) - 2026-06-20
[Section titled “0.15.0 - 2026-06-20”](#0150---2026-06-20)
`wt sync` rebases the whole chain of worktrees in one command.
### Added
[Section titled “Added”](#added-15)
* **`wtm wt sync`** rebases every worktree onto its refreshed parent in topological order, replaying only its own commits, locally.
* **`wtm wt sync`** shows a recap, then offers one `--force-with-lease` push of the rebased branches.
* **`--dry-run`**, **`--base`**, **`--push`**, **`--no-push`** and **`--yes`** on `wt sync`.
* **`wtm wt sync --output json`** reports a status per branch and exits non-zero on a conflict or error.
### Fixed
[Section titled “Fixed”](#fixed-13)
* **`wtm wt sync`** reports a failing git command as an error instead of `up_to_date`.
## [0.14.0](https://github.com/LucasPcq/wtm/releases/tag/v0.14.0) - 2026-06-20
[Section titled “0.14.0 - 2026-06-20”](#0140---2026-06-20)
Worktree lists show up instantly while PRs stream in.
### Added
[Section titled “Added”](#added-16)
* **`wtm wt list --with-prs`** includes PRs in non-interactive and JSON output.
### Changed
[Section titled “Changed”](#changed-13)
* **`wt list`**, **`wt go`** and **`wt switch`** show worktrees immediately, PR badges filling in as they arrive.
* **`wt list`** no longer fetches PRs by default in non-interactive output.
## [0.13.0](https://github.com/LucasPcq/wtm/releases/tag/v0.13.0) - 2026-06-20
[Section titled “0.13.0 - 2026-06-20”](#0130---2026-06-20)
`wtm init` reworked: skip sections, re-initialise one with `--only`, edit `on_create` hooks.
### Added
[Section titled “Added”](#added-17)
* **`wtm init`** lets you skip `env`, `hooks` or `services`, written commented out; `--skip-env`, `--skip-hooks`, `--skip-services` do it non-interactively.
* **`wtm init --only `** re-initialises one section without touching the others.
* **`wtm init`** edits `on_create` hooks as a list: add, edit, remove, reorder.
### Removed
[Section titled “Removed”](#removed-1)
* **The install command and monorepo packages steps** of `wtm init`: use the `on_create` hook editor.
## [0.12.0](https://github.com/LucasPcq/wtm/releases/tag/v0.12.0) - 2026-06-20
[Section titled “0.12.0 - 2026-06-20”](#0120---2026-06-20)
`wt extract` moves uncommitted changes between worktrees.
### Added
[Section titled “Added”](#added-18)
* **`wtm wt extract`** moves part of the current worktree's uncommitted changes to a new or existing worktree; `--keep` copies them instead.
* **`wtm wt extract`** leaves the source untouched unless the whole extraction applied.
* **`--on-conflict abort`** (default) changes nothing and exits `15`; **`resolve`** writes conflict markers in the target.
## [0.11.0](https://github.com/LucasPcq/wtm/releases/tag/v0.11.0) - 2026-06-19
[Section titled “0.11.0 - 2026-06-19”](#0110---2026-06-19)
wtm can be driven by agents, and detached services stream their startup logs.
### Breaking
[Section titled “Breaking”](#breaking-9)
* **Outside an initialised repository**, commands exit `12` instead of `0`: adjust scripts relying on a silent success.
* **`wtm pr create`** exits `13` when a PR already exists, instead of `0`.
### Added
[Section titled “Added”](#added-19)
* **`wtm init --non-interactive`** bootstraps a project from flags, then detection, then defaults.
* **`wtm pr create --yes`** pushes an unpushed branch and skips prompts.
* **`wtm wt create --if-not-exists`** succeeds when the worktree already exists.
* **Exit codes per failure**: `10` worktree exists, `11` branch not found, `12` config not found, `13` PR exists, `14` job not declared.
* **`wtm run up`** streams a detached service's startup output instead of a spinner.
### Changed
[Section titled “Changed”](#changed-14)
* **`wt clean`**, **`run stop`** and **`run down`** succeed as no-ops when there is nothing to remove or stop.
### Fixed
[Section titled “Fixed”](#fixed-14)
* **`wtm pr create --output json`** no longer stops silently on an unpushed branch.
## [0.10.0](https://github.com/LucasPcq/wtm/releases/tag/v0.10.0) - 2026-06-09
[Section titled “0.10.0 - 2026-06-09”](#0100---2026-06-09)
`run up` and `run start` launch and tail in one step, and profiles run jobs in your order.
### Added
[Section titled “Added”](#added-20)
* **`wtm run up`** / **`run start`** start jobs and stream their output straight away.
* **`run profile add`** / **`edit`** order a profile's jobs, followed at run time.
### Changed
[Section titled “Changed”](#changed-15)
* **A failed task** aborts the rest of the profile and shows its logs.
* **The `wt go` / `wt switch` picker** loads faster and shows a callout when `gh` is missing.
## [0.9.0](https://github.com/LucasPcq/wtm/releases/tag/v0.9.0) - 2026-06-08
[Section titled “0.9.0 - 2026-06-08”](#090---2026-06-08)
An interactive `wt list`, `run.toml` export/import and commands to edit jobs and profiles.
### Breaking
[Section titled “Breaking”](#breaking-10)
* **Config and metadata** move from `.wtm/` to `/wtm/`: move existing files or re-run `wtm init`.
* **`wtm run list --output json`** uses lowercase keys (`job`, `name`, `kind`).
* **The shell wrapper** changed: re-run `eval "$(wtm shell-init)"`.
### Added
[Section titled “Added”](#added-21)
* **`wtm wt list`** is interactive, with an **Open PR** action and a hint when `gh` is missing.
* **Removing the current worktree** sends the shell back to the base repository.
* **`wtm init`** offers `package.json` scripts, workspaces included, as jobs.
* **`wtm run export`** / **`run import`** move `run.toml` in and out as JSON.
* **`wtm run job`** and **`wtm run profile`** `add|rm|edit|list` manage `run.toml` by wizard or flags.
* **`wtm config show`** and **`wtm config edit`** reach the config without digging into the git directory.
### Changed
[Section titled “Changed”](#changed-16)
* **Setting a default profile** unsets the previous one instead of failing.
## [0.8.0](https://github.com/LucasPcq/wtm/releases/tag/v0.8.0) - 2026-05-01
[Section titled “0.8.0 - 2026-05-01”](#080---2026-05-01)
Config files are decoded strictly and come with JSON Schemas for IDE autocomplete.
### Added
[Section titled “Added”](#added-22)
* **JSON Schemas** for `run.toml` and both `config.toml` are written next to them, for autocomplete in Taplo-based editors.
* **`wtm schema dump`** refreshes the schemas on disk after an upgrade.
### Fixed
[Section titled “Fixed”](#fixed-15)
* **Unknown keys** in a config file are rejected instead of ignored.
## [0.7.2](https://github.com/LucasPcq/wtm/releases/tag/v0.7.2) - 2026-04-29
[Section titled “0.7.2 - 2026-04-29”](#072---2026-04-29)
The terminal is restored after detaching from a job's logs.
### Fixed
[Section titled “Fixed”](#fixed-16)
* **`wtm run logs`** on a job with a TUI (turbo, vite, vim) no longer leaves the terminal broken on exit.
## [0.7.1](https://github.com/LucasPcq/wtm/releases/tag/v0.7.1) - 2026-04-29
[Section titled “0.7.1 - 2026-04-29”](#071---2026-04-29)
Stopping a job stops its whole process tree.
### Fixed
[Section titled “Fixed”](#fixed-17)
* **`wtm run stop`** / **`run down`** stop the job's whole process group, with SIGKILL after 5 s if SIGTERM is ignored.
## [0.7.0](https://github.com/LucasPcq/wtm/releases/tag/v0.7.0) - 2026-04-29
[Section titled “0.7.0 - 2026-04-29”](#070---2026-04-29)
Services and one-shot tasks are unified as jobs in `run.toml`.
### Breaking
[Section titled “Breaking”](#breaking-11)
* **`.wtm/services.toml`** is replaced by **`.wtm/run.toml`**, with `[[job]]` and `[[profile]]`: rewrite your file.
* **`wtm svc`** is renamed **`wtm run`**: update scripts and aliases.
* **The `wt switch` shell wrapper** calls `wtm run up`: regenerate it with `wtm shell-init`.
### Added
[Section titled “Added”](#added-23)
* **`kind = "task"`** declares a one-shot command (migration, seed) that streams live and must succeed before the profile continues.
* **`run.toml`** is validated before anything runs.
### Changed
[Section titled “Changed”](#changed-17)
* **`wtm init`** writes detected docker-compose files as detached services.
## [0.6.2](https://github.com/LucasPcq/wtm/releases/tag/v0.6.2) - 2026-04-13
[Section titled “0.6.2 - 2026-04-13”](#062---2026-04-13)
More output polish.
### Fixed
[Section titled “Fixed”](#fixed-18)
* **The `wt go` / `wt switch` picker** keeps its colours through the shell wrapper.
## [0.6.1](https://github.com/LucasPcq/wtm/releases/tag/v0.6.1) - 2026-04-12
[Section titled “0.6.1 - 2026-04-12”](#061---2026-04-12)
Output polish.
### Changed
[Section titled “Changed”](#changed-18)
* **`svc up`**, **`svc ps`**, **`pr create`** and **`wtm init`**: consistent padding and spacing.
### Fixed
[Section titled “Fixed”](#fixed-19)
* **The `wt go` / `wt switch` picker** keeps its highlight and badges through the shell wrapper.
## [0.6.0](https://github.com/LucasPcq/wtm/releases/tag/v0.6.0) - 2026-04-12
[Section titled “0.6.0 - 2026-04-12”](#060---2026-04-12)
wtm can be driven by LLM agents.
### Added
[Section titled “Added”](#added-24)
* **`--output json`** on the `wt`, `pr` and `svc` commands, with human text on stderr.
* **`wtm svc list`** lists declared services and profiles, with actions on a terminal.
* **`wtm svc ps`** lists running services, with stop, logs and restart actions.
* **`wtm agents install`** installs a `using-wtm` skill into the `.claude/` or `.cursor/` directories it finds.
* **`wtm init`** detects docker-compose files and scaffolds matching services.
* **`wtm svc down --all`** stops every service of every worktree.
### Changed
[Section titled “Changed”](#changed-19)
* **`wt switch`** without an argument shows the `wt list` picker.
* **`wtm svc down`** without `--all` only touches the current worktree.
### Fixed
[Section titled “Fixed”](#fixed-20)
* **A service with a `stop` command** no longer reports `✓ started` when `docker compose up -d` fails.
* **`svc down`**, `wt clean` and `svc up --exclusive` no longer stop other worktrees' services.
## [0.5.1](https://github.com/LucasPcq/wtm/releases/tag/v0.5.1) - 2026-04-12
[Section titled “0.5.1 - 2026-04-12”](#051---2026-04-12)
Pickers and shell navigation work through the shell wrapper.
### Fixed
[Section titled “Fixed”](#fixed-21)
* **The `wt go` / `wt switch` picker** is visible through the shell wrapper.
* **"Go to worktree"** from `pr list` and `wt list` navigates instead of asking for shell integration.
* **The shell wrapper** lets any subcommand change the directory.
## [0.5.0](https://github.com/LucasPcq/wtm/releases/tag/v0.5.0) - 2026-04-12
[Section titled “0.5.0 - 2026-04-12”](#050---2026-04-12)
New pickers and wizards, `wt switch`, and focus removed in favour of services.
### Breaking
[Section titled “Breaking”](#breaking-12)
* **`wtm wt focus`** and active-worktree tracking are removed: use `svc up` / `svc down`.
* **`on_focus`** / **`on_blur`** hooks are removed: keep `on_create`, and let services run Docker.
* **The dashboard** is hidden while it is reworked: `wtm` alone shows help.
### Added
[Section titled “Added”](#added-25)
* **`wtm wt switch [branch]`** goes to a worktree and runs `svc up`.
* **`svc up`** offers to stop services running in other worktrees; `--exclusive` stops them, `--parallel` skips the question.
* **`wt clean`** stops a worktree's services before deleting it.
* **Pickers and wizards** filter on `/`, show a breadcrumb and go back with `Esc`.
* **The `pr list` picker** offers to go to or check out the PR's worktree; **`wt list`** shows badges.
### Changed
[Section titled “Changed”](#changed-20)
* **Output**: one style and indent for every message and error.
### Fixed
[Section titled “Fixed”](#fixed-22)
* **`docker compose up -d` services** are tracked and stopped properly.
* **`svc` commands** read `services.toml` from the main worktree when run from another.
### Removed
[Section titled “Removed”](#removed-2)
* **The docker-compose and hook steps** of the `wtm init` wizard.
## [0.4.1](https://github.com/LucasPcq/wtm/releases/tag/v0.4.1) - 2026-04-12
[Section titled “0.4.1 - 2026-04-12”](#041---2026-04-12)
GitHub access goes through the `gh` CLI.
### Breaking
[Section titled “Breaking”](#breaking-13)
* **`wtm auth login|status|logout`** are removed: install `gh` and run `gh auth login`.
* **`WTM_GITHUB_TOKEN`** is no longer read: use `GH_TOKEN`.
### Changed
[Section titled “Changed”](#changed-21)
* **`pr` commands** and the dashboard's PR panel use `gh`.
## [0.4.0](https://github.com/LucasPcq/wtm/releases/tag/v0.4.0) - 2026-04-10
[Section titled “0.4.0 - 2026-04-10”](#040---2026-04-10)
GitHub integration and pull-request commands.
### Breaking
[Section titled “Breaking”](#breaking-14)
* **Worktree commands** move under `wtm wt` and service commands under `wtm svc`: update scripts and aliases.
### Added
[Section titled “Added”](#added-26)
* **`wtm auth login`** signs in to GitHub with the device flow, with `auth status` and `auth logout`.
* **`wtm pr list`** lists pull requests, with `--mine` and `--review`, also in the dashboard.
* **`wtm pr create`** creates a PR from the current branch through a wizard.
* **`wtm pr checkout`** creates a worktree from an existing PR.
* **`wtm svc start`** / **`stop`** act on single services, **`up`** / **`down`** on profiles.
### Changed
[Section titled “Changed”](#changed-22)
* **The dashboard** splits worktrees and PRs, and multiplexes logs.
### Fixed
[Section titled “Fixed”](#fixed-23)
* **Dashboard** focus handling.
## [0.3.0](https://github.com/LucasPcq/wtm/releases/tag/v0.3.0) - 2026-04-04
[Section titled “0.3.0 - 2026-04-04”](#030---2026-04-04)
Services run in a background daemon, each in its own terminal.
### Breaking
[Section titled “Breaking”](#breaking-15)
* **Project config** moves from `.wtm.toml` to `.wtm/config.toml`: move the file.
### Added
[Section titled “Added”](#added-27)
* **`wtm up`** starts a profile's services from `.wtm/services.toml` in a background daemon, scoped to the worktree.
* **`wtm down`** stops them; **`wtm logs`** attaches to a service's terminal.
* **The dashboard** starts, stops and attaches to the selected worktree's services.
## [0.2.1](https://github.com/LucasPcq/wtm/releases/tag/v0.2.1) - 2026-04-03
[Section titled “0.2.1 - 2026-04-03”](#021---2026-04-03)
A fix for the dashboard launched from the shell.
### Fixed
[Section titled “Fixed”](#fixed-24)
* **The dashboard** opens through the shell wrapper.
## [0.2.0](https://github.com/LucasPcq/wtm/releases/tag/v0.2.0) - 2026-04-03
[Section titled “0.2.0 - 2026-04-03”](#020---2026-04-03)
An interactive dashboard.
### Added
[Section titled “Added”](#added-28)
* **`wtm`** without arguments opens a full-screen dashboard of every worktree.
* **The dashboard** shows each worktree's status and details, and creates, cleans, focuses and navigates to worktrees.
* **Focusing** from the dashboard streams hook output live.
* **`wtm new`** asks for the branch name when none is given.
### Fixed
[Section titled “Fixed”](#fixed-25)
* **Hook errors** in the dashboard no longer corrupt the screen.
## [0.1.2](https://github.com/LucasPcq/wtm/releases/tag/v0.1.2) - 2026-04-02
[Section titled “0.1.2 - 2026-04-02”](#012---2026-04-02)
Fixes for commands run from a child worktree.
### Fixed
[Section titled “Fixed”](#fixed-26)
* **Commands run from a child worktree** find the project config.
* **Blur hooks** no longer fail when the previous worktree's directory is gone.
* **The shell wrapper** returns to the main worktree after cleaning the current one.
## [0.1.1](https://github.com/LucasPcq/wtm/releases/tag/v0.1.1) - 2026-04-02
[Section titled “0.1.1 - 2026-04-02”](#011---2026-04-02)
Initial release.
### Added
[Section titled “Added”](#added-29)
* **`wtm init`** sets up global and project configuration (`.wtm.toml`) through a wizard.
* **`wtm new [branch]`** creates a worktree with env provisioning, metadata and hooks.
* **`wtm ls`** lists worktrees with their git status.
* **`wtm go [branch]`** moves to a worktree through shell integration.
* **`wtm focus [branch]`** switches the active worktree and runs `on_blur` / `on_focus` hooks.
* **`wtm clean [branch]`** removes a worktree, refusing when it is dirty, unpushed or has an open PR.
* **`wtm shell-init`** generates the shell wrapper for zsh, bash and fish.
* **Env strategies** `example`, `main` and `parent`, and hooks with template variables.
* **Detection** of the base branch, env files, package manager, Docker Compose and pnpm workspaces.
* **Install** with Homebrew (`brew install LucasPcq/tap/wtm`), release binaries or `go install`.
# Developer documentation
Reference documentation for people (and agents) working **on** wtm. It describes the code as delivered, not the design that preceded it.
> The rest of `docs/` is **generated** by `tools/gendocs` from the Cobra command tree (`make docs`) and must never be hand-edited. `docs/dev/` is hand-written and is the only manual content under `docs/`; gendocs only writes `wtm_*.md` and `commands.json` (the command reference grouped like `wtm --help`, read by the documentation site) at the root of `docs/`, so this subdirectory survives a regeneration untouched.
| Document | What it covers |
| --------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [architecture.md](/dev/architecture/) | The annotated package map, who may call whom and what each interdiction buys, how a command designates a worktree |
| [flow-layer.md](/dev/flow-layer/) | `internal/flow/` — the three seams, the step model, unattended resolution, embedding, scheduling, events, testing a flow |
| [adding-a-mutation-command.md](/dev/adding-a-mutation-command/) | End-to-end recipe for a new worktree-mutating command |
| [output.md](/dev/output/) | What a command prints: the one question a block has to answer, the frame and the accent bar, the four levels, the two shapes of a conclusion, the glyph vocabulary, `--quiet` |
| [run-addressing.md](/dev/run-addressing/) | Named URLs: proxy vs redirection vs public port, and what `addressing` writes into a `.env` |
| [shared-services.md](/dev/shared-services/) | `scope = "shared"`: one instance for the repository, one namespace per worktree, and why the job table is the reference count |
| [lint.md](/dev/lint/) | `make lint` and what each gate holds: the `archlint` rules, the exception files, the pre-commit hook, the rules considered and left out |
| [changelog.md](/dev/changelog/) | How to write `CHANGELOG.md`: the release template, the rules, and how a section becomes the GitHub release notes |
For the coding standards themselves (immutability, struct params, constants, comment density), see [`CLAUDE.md`](https://github.com/LucasPcq/wtm/blob/main/CLAUDE.md) and the `go-cli` skill in `.claude/skills/go-cli/SKILL.md`.
To see a change working in the real binary — an isolated sandbox driven with tmux, or recorded with VHS, optionally before/after — use the `wtm-sandbox` skill (`.claude/skills/wtm-sandbox/`). To open a pull request (base branch from `wtm tree`, that terminal proof attached with `gh --attach`, body template), use the `open-pr` skill (`.claude/skills/open-pr/`).
# Adding a worktree-mutating command
A *mutation command* is one that changes worktree state: it creates, removes, moves or rewrites something, and therefore has questions to ask, safety refusals to honor, and two bypass axes to expose. Every new one goes through `internal/flow/` — the model in [flow-layer.md](/dev/flow-layer/).
A read-only command (`list`, `tree`, `resolve`) needs none of this: parse flags, call the service, hand the result to `output/`.
## 1. Declare the vocabulary in `domain/`
[Section titled “1. Declare the vocabulary in domain/”](#1-declare-the-vocabulary-in-domain)
Constants first, so nothing downstream invents a string:
* `domain.CmdSplit` for the command name, `domain.FlagInto` for each new flag (`internal/domain/constants.go`).
* Every user-visible label, description, recap field and skip reason. A step's prose lives in `constants.go`, not as a literal in the flow.
* A sentinel in `internal/domain/errors.go` for each required selection that has no safe default, worded so it names the flag: `ErrSplitTargetRequired`.
* If a surface will have to schedule it, an `OpKind` constant.
## 2. Put the decisions in `rules/`
[Section titled “2. Put the decisions in rules/”](#2-put-the-decisions-in-rules)
Anything that is a *decision over data* — is this safe, which of these applies, what is the default here — is a pure function in `internal/rules/`, taking domain types and returning domain types. No I/O, no writing. It is then testable as a table and callable from the flow, the service and any surface.
## 3. Write the flow package
[Section titled “3. Write the flow package”](#3-write-the-flow-package)
`internal/flow/split/split.go` — the run:
```go
type Request struct {
Branch string
Into string
Force bool // the safety axis, if the command has refusals to lift
}
type Outcome struct {
Branch string
Result domain.SplitResult
Aborted bool
}
type Presenter interface {
flow.Presenter
Split(Outcome) error // the typed conclusion, one per command
}
type Params struct {
Context flow.Context
Request Request
Prompter flow.Prompter
Presenter Presenter
}
func Run(params Params) (Outcome, error) {
f := &splitFlow{ctx: params.Context, request: params.Request,
prompter: params.Prompter, presenter: params.Presenter}
return f.run()
}
```
Rules that are not negotiable:
* The package imports **only** `internal/service`, `internal/rules`, `internal/domain` and the stdlib. Never cobra, bubbletea, lipgloss, `internal/output`, `internal/tui`, `internal/config` or `internal/commands`. If you need something only `infra/` has, add a thin wrapper in `service/` — as `worktree.FindByBranch` does.
* `Request` carries **no `--yes` and no `--output`**. `--force` does belong there.
* Errors are returned. A user abort is `presenter.Notice(flow.AbortedNotice)` followed by `Outcome{Aborted: true}, nil`.
* Long work goes through `presenter.Stage`; hook output through `presenter.HookPhase`; a line inside an ongoing phase through `presenter.Status`. The flow never prints.
`internal/flow/split/steps.go` — the questions:
```go
const (
KeyBranch = "split.branch"
KeyInto = "split.into"
KeyRecap = "split.recap"
)
func (f *splitFlow) session() flow.Session {
return flow.Session{
ErrLabel: domain.SplitWizardErrLabel,
Presets: flow.NewAnswers(map[string]string{
KeyBranch: f.request.Branch,
KeyInto: f.request.Into,
}),
Steps: []flow.Step{f.branchStep(), f.intoStep(), f.recapStep()},
}
}
```
For **each** step, decide its `Resolve` — that is the bypass taxonomy, and it is the step's own business:
| The step is… | `Resolve` |
| ----------------------------------------- | ---------------------------------------------------------------------------- |
| a decision with a safe default | returns that `Answer`. Never destructive. |
| a required selection with no safe default | returns an error naming the flag, usually the domain sentinel |
| answerable only by a human | omitted entirely — `flow.Unattended` then refuses, naming `Label` and `Flag` |
And the rest of the fields:
* `Skip func(Answers) (skip bool, reason string)` when the step can become irrelevant. The reason is user-visible; put it in `constants.go`.
* `Build` for content derived from earlier answers, `Load` when deriving it does I/O (plus `LoadingMessage`). Never do slow work in `Build`.
* `Summarize` when the raw answer value is not what the user should read back.
* `Flag` so a refusal can name the flag that would have answered the step.
* `Blockers` on the `StepContent` of a step whose dangerous option is gated by safety refusals — one entry per refusal, never folded into the prose, so a surface can have them lifted one at a time.
* The **recap is always the last step** and always unconditional. Its `Build` names every part of the plan, including the parts a flag resolved — read the value from `Answers`, which returns presets too. A flag must never make a line disappear.
If a surface may run several of these at once, declare how:
```go
func Operation() flow.Operation {
return flow.Operation{Kind: domain.OpKindSplit, Mode: flow.ModeBlocking, TargetKey: KeyBranch}
}
```
## 4. Wire the command
[Section titled “4. Wire the command”](#4-wire-the-command)
`internal/commands/wt/split.go` holds flag wiring and nothing else:
```go
func runSplit(cmd *cobra.Command, args []string) error {
into, _ := cmd.Flags().GetString(domain.FlagInto)
force, _ := cmd.Flags().GetBool(domain.FlagForce)
yes, _ := cmd.Flags().GetBool(domain.FlagYes)
format, _ := cmd.Flags().GetString(domain.FlagOutput)
if format == domain.OutputJSON && !yes {
return domain.ErrSplitJSONNeedsYes
}
dir, err := os.Getwd()
if err != nil {
return fmt.Errorf("get working directory: %w", err)
}
config, err := shared.LoadConfig(cmd, dir)
if err != nil {
return err
}
// The one place --yes is read: which Prompter gets installed.
interactive := rules.IsHumanFormat(format) && !yes && term.IsTerminal(int(os.Stdin.Fd()))
_, err = splitflow.Run(splitflow.Params{
Context: flowContext(config),
Request: splitflow.Request{Branch: branchName, Into: into, Force: force},
Prompter: flowPrompter(flowPrompterParams{Interactive: interactive}),
Presenter: splitPresenter{cliPresenter: newPresenter(cmd, format)},
})
return err
}
```
Flag help strings are uniform:
* `--yes` / `-y` — *"Skip all prompts; resolve every decision from flags and safe defaults (…)"*
* `--force` — *"Lift safety refusals (…); still asks to confirm unless --yes"*
Register the command in its parent group and give it a `GroupID` (`domain.CmdGroup*`), or it renders under a stray "Additional Commands" heading.
## 5. Add the CLI presenter
[Section titled “5. Add the CLI presenter”](#5-add-the-cli-presenter)
In `internal/commands/wt/presenter.go`, next to `createPresenter` and `cleanPresenter`:
```go
type splitPresenter struct{ cliPresenter }
func (p splitPresenter) Split(outcome splitflow.Outcome) error {
if p.format == domain.OutputJSON {
return output.WriteSplitJSON(p.cmd.OutOrStdout(), outcome.Result)
}
output.Frame(p.cmd.OutOrStdout(), func() {
output.FormatSplitResult(p.cmd.OutOrStdout(), /* … */)
})
return nil
}
```
`cliPresenter` already implements `Stage`, `HookPhase`, `Notice` and `Status` — embed it and add only the typed conclusion. The frame is applied **exactly once**, here; JSON and machine output are never framed.
## 6. Test it
[Section titled “6. Test it”](#6-test-it)
* **Flow tests** in the flow package, with `flowtest.ScriptedPrompter` and `flowtest.Recorder`. Assert on the answers that were asked (`AskedKeys()`), on the `StepContent` a step produced (the recap prose is user-visible behavior), and on the outcome.
* **Unattended tests** with `flow.Unattended{}` directly: each required selection refuses and names its flag, each defaulted decision lands on the safe value.
* **Integration tests** at the Cobra level (`gittest.InitRepo` + `WTM_PROJECT_DIR` / `WTM_STATE_DIR`) for the two axes end to end: `--yes` without the required flag errors, `--force` alone still confirms, `--output json` requires `--yes`.
## 7. Documentation
[Section titled “7. Documentation”](#7-documentation)
1. `make docs` — regenerates `docs/`, never hand-edited.
2. Add the command to the `README.md` overview table, in the same group as the root `--help`.
3. Update the agent skill (`internal/commands/agents/assets/using-wtm/`, the reference file of the command's topic) if the agent-facing surface changed (a new command, a new flag, a changed JSON shape, changed failure/abort semantics).
4. Run the `build-validator` subagent. Step 6 fails the run if `internal/flow/` gained a forbidden import.
## Optional: make it work in the dashboard
[Section titled “Optional: make it work in the dashboard”](#optional-make-it-work-in-the-dashboard)
Nothing in the flow changes. In `internal/tui/dashboard/actions.go`, add a `startSplit` that checks `busyReason`, calls `beginOp(splitflow.Operation())`, and launches `splitflow.Run` in a `tea.Cmd` with the dashboard's `prompter` and a presenter embedding `dashboard.presenter` plus the typed conclusion. If the flow uses a step kind the dashboard's modal cannot render, that is the only work left — and `flowui` will refuse an unknown kind rather than guess, so you will hear about it immediately.
# Architecture — the layers and what they buy
wtm is a Cobra CLI with two interactive surfaces (an inline wizard and the `wtm ui` dashboard) over one set of git operations. The layering exists so that a command's *flow* — the order of its questions, its safety checks, its service calls — is written once and can be replayed by either surface.
## The map
[Section titled “The map”](#the-map)
One line per package: what it owns. The import rules between them are the next section.
```plaintext
cmd/ ← entry points, cobra setup only
internal/
commands/ ← flag wiring, delegates to flow/service (zero business logic)
run/runctx/ ← what every `run` command opens on: its directory, the config,
run.toml, the opt-in guard and the prompt gate
daemon/ ← the hidden `daemon` command and the macOS port-80 relay launchd runs
ui/ ← `wtm ui`: refuses JSON and a missing TTY, then hands off to tui/dashboard
events/ ← `wtm events`: the stream (text or JSON Lines) over service/events.Watch,
or WatchAll outside any repository
versioncmd/ ← `wtm version`: the binary's version and each machine contract's (`events`)
domain/ ← types, errors, constants only (no methods, no functions)
rules/ ← pure functions (stdlib + domain only, no I/O)
config/ ← load & validate config.toml + run.toml from /wtm/, plus the global config (config.GlobalPath);
every write puts the file's JSON schema (schemas/) beside it
flow/ ← the flow of each command, surface-independent (see below):
the vocabulary (Step, Session, Prompter, Presenter, Publisher)
publish/ ← the event a flow publishes after a change to a worktree's
identity, read back from git and meta.json (`wtm events`)
ordinal/ ← allocating a worktree's ordinal from the flow that needs it,
published as `worktree.updated`; the service only reads it
decide/ ← branch/env decisions shared by the create-like flows
envports/ ← settling a fresh .env's host ports onto the ones the
worktree binds, per its isolation (isolated / verbatim,
recorded in meta.json and read by the daemon too) —
shared by `create`, `extract` and `checkout`, which it
never fails: a refused run.toml or an unreadable ordinal
is a warning (`SettleFresh`), the run part left undone
create/ ← `wtm create`: the run (create.go) + its questions (steps.go)
checkout/ ← `wtm checkout`: the run (checkout.go) + its questions (steps.go)
clean/ ← `wtm clean`: the run (clean.go) + its questions (steps.go)
reparent/ ← `wtm reparent`: the run (reparent.go) + its questions (steps.go)
prune/ ← `wtm prune`: the run (prune.go) + its questions (steps.go)
extract/ ← `wtm extract`: the run (extract.go) + its questions (steps.go),
create's own embedded through `create.Embed`
env/ ← `wtm env`: the run (env.go) + its questions (steps.go), the
pre-scan the wizard reads (scan.go), how the worktree runs
today that its isolation and addressing steps keep or switch
(mode.go), and the port pass and isolation switch (pass.go);
its per-key resolver is its own kind, `flow.StepEnvResolve`
relocate/ ← `wtm relocate`: the run (relocate.go) + its questions (steps.go);
the move, the adoption and the base_path rewrite are three
separate service calls (`worktree.Move`/`Adopt`/`SetBasePath`)
teardown/ ← the removal clean and prune share, one worktree or a
batch (`Batch`): stop, hooks, remove, drop — then
release every claim, all together
orphans/ ← the question clean and prune ask about the children a
removal orphans: the step, its preset, its recap line
sync/ ← `wtm sync`: the run (sync.go) + its questions (steps.go)
fastforward/ ← `wtm fast-forward`: the run + its questions
exec/ ← `wtm exec`: the run + its questions
runlogs/ ← the jobs a surface shows (`Board`), their live streams,
and the profile start sequence (reports events, not steps)
run/ ← the `run` module's flows, mirroring its command tree:
target/ ← the questions they share (worktree, job, profile,
and the published-url step `run open` asks)
urls/ ← where every address the module hands out is computed
seam/ ← the daemon as a flow uses it: board, env, log dir,
port prober, and the start sequence a surface drives
foreigndata/ ← the stop before a job whose `touches` reach data the
worktree does not own, shared by `up` and `start`
probes/ ← the offer to write `probe = false` for a job bound to its
base port, made after `up` and `start` alike
owed/ ← paying the namespace drops a clean deferred, whenever a run
finds their shared service up
addressing/ ← `run addressing`: switch the mode, settle the worktrees' .env
concurrency/ ← the question about the other worktrees' jobs (load or
port clash, `--exclusive`/`--parallel`), shared by `up` and `start`
up/ down/ start/ ← one package per command, as everywhere else
stop/ logs/ open/ url/
list/ ← `run list`: which entry was picked and what to do to it
job/ profile/ ← CRUD on run.toml's declarations, one package per group
initrun/ ← `run init`: detect, ask (the services wizard is its own
`Wizard` seam, not a flow.Session), write run.toml,
compose and .env files
service/ ← impure orchestration only (git exec, I/O, hooks):
worktree/ ← git worktree operations (create, list, remove)
env/ ← .env provisioning (create) + drift reconciliation (`wtm env`, sync.go)
hooks/ ← on_create / on_clean hook execution (a /bin/sh line each)
shell/ ← shell integration generation (zsh, bash, fish)
integration/ ← third-party adapters: handing a URL to the desktop's
own opener (editor/agent detection lives in detect/)
proxy/ ← the run proxy: the host→job routing table and the
loopback server the daemon owns (`[proxy]`)
detect/ ← auto-detection (base branch, env files, package manager)
branch/ ← branch candidates for the pickers (local + origin, divergence)
github/ ← pull requests through the `gh` CLI
selfupdate/ ← how wtm was installed, and `wtm upgrade`
process/ ← the run daemon: jobs on PTYs, the durable index (jobs.json),
reaping orphans, the client the commands talk through, and
the schema-blind event broker (`publish` / `subscribe`)
events/ ← the `wtm events` bus as wtm uses it: the Publisher every flow
reports through, Watch (subscribe → snapshot → ready), the
registry of repositories wtm was used in (`repos.json`, through
`infra/registry.go`) and WatchAll, which follows all of them
runconfig/ ← load + validate + write run.toml (and its schema)
runjobs/ ← the daemon's jobs as a surface reads them (the dashboard too)
compose/ ← a compose file's `ports:` and absolute names, read and rewritten
portprobe/ ← is anything listening on a port
shellcmd/ ← checks that a config command is a valid /bin/sh line
execsvc/ ← runs one shell line in several worktrees at once
output/ ← format and print results (zero decision logic)
styles/ ← all Lipgloss styles (only package allowed to instantiate lipgloss.Style)
tui/ ← Bubbletea models (zero business logic, rendering only)
flowui/ ← runs a flow.Session as a wizard (the only translator
between flow.Step and components.Step)
dashboard/ ← `wtm ui`: the full-screen worktree dashboard, the second
surface over flow/ (its own Prompter/Presenter, mouse
zones via bubblezone). It also hands the terminal to
runview (`handoff.go`) for the run flows that draw
runview/ ← a job's raw PTY output replayed through a terminal
emulator (`github.com/charmbracelet/x/vt`)
infra/ ← I/O, git exec, filesystem wrappers
```
## Who may call whom
[Section titled “Who may call whom”](#who-may-call-whom)
```mermaid
flowchart TD
commands["commands/"] --> flow["flow/"]
commands --> output["output/"]
commands --> tui["tui/"]
commands --> config["config/"]
tui --> flow
flow --> service["service/"]
flow --> rules["rules/"]
service --> infra["infra/"]
service --> rules
rules --> domain["domain/"]
flow --> domain
output --> styles["styles/"]
tui --> styles
```
Every arrow that is *missing* is the point:
| Interdiction | What it buys |
| ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| `commands/` has no business logic | A command is readable as flags in, one call out. Changing the flow never means editing flag parsing. |
| `domain/` holds types, errors and constants only | Nothing can acquire a dependency by hiding behind a method on a shared type. |
| `rules/` imports only stdlib + `domain/` | Decisions stay testable with no repo, no network, no temp dir. `rules.DecidePush` is a table test, not an integration test. |
| `service/` never imports cobra, bubbletea or lipgloss | The git operations are callable from a test, a flow, a daemon — anything that is not a terminal. |
| `output/` and `tui/` hold no decision logic | Two surfaces can render the same run without disagreeing about what it means. |
| `styles/` is the only package instantiating `lipgloss.Style` | A theme change is one file. |
| `flow/` imports only `service/`, `rules/`, `domain/` and the stdlib | The flow cannot grow a dependency on the surface that runs it. This is what makes a second surface possible at all — see below. |
`flow/` cannot reach `infra/` either. When a flow needs something only `infra/` has, the fix is a thin `service/` wrapper, not an exception: `worktree.FindByBranch` and `worktree.ListAll` exist for exactly that reason.
Two more edges are constrained beyond the diagram:
* `service/x` imports `service/y` only along an edge declared in `tools/archlint` (`serviceEdges`): today `detect→branch`, `events→{process,worktree}`, `process→proxy`, `runconfig→shellcmd`, `runjobs→{process,runconfig,worktree}`, `worktree→{branch,env,github,hooks,process}`.
* The daemon — `service/process` and `service/proxy`, which it serves — is blind to git: neither imports `service/worktree`, `service/branch`, `service/github`, `service/events` or `config`, and both call only allow-listed `infra/` functions (`GlobalDir`).
* A service **mutator** (`worktree.Create`, `envsvc.ApplyEnvSync`, `runconfig.Save`, … — the table in `tools/archlint/chokepoint.go`) is called only from `internal/flow/`, whatever the calling layer; a call inside the mutator's own package is its implementation.
None of this is left to review: `make lint` runs `tools/archlint`, and each rule above is one of its analyzers. The full list, and how to add one, is in [lint.md](/dev/lint/).
## How a command designates a worktree
[Section titled “How a command designates a worktree”](#how-a-command-designates-a-worktree)
One rule, no exception: **the subject is positional, and a worktree that is not the subject is a flag named after its role.** `clean [branch]`, `env [worktree]`, `extract [source]`, `sync [branch...]` take their subject positionally; `extract --to`, `create --from`, `sync --base` name a second worktree. The `run` module follows the same rule with the worktree as its subject — `run up [worktree] --profile`, `run start [worktree] --job` — so the job and the profile are flags. A new command adds no third form.
Omitting the positional resolves in one of two ways, and which one is not a matter of taste: **the current directory when it is a safe default for that command, a picker otherwise.** `run` has one (you are standing in the worktree whose services you want), so a non-interactive run silently takes it — category 1 of the bypass model, no exception to write. `clean` has none (which worktree would it destroy?), so it errors or opens a picker — category 2.
Whatever answers, a resolved worktree is always **the worktree root as git spells it** (`infra.Toplevel`), never a raw `os.Getwd()`. The daemon keys a job on `name + WorkDir` by string equality *and* runs it there, resolving `run.toml`'s `cwd` against it: a subdirectory, or macOS's `/var` where git says `/private/var`, splits one worktree into two keys and mis-resolves every relative `cwd`.
## The founding observation: seven closures
[Section titled “The founding observation: seven closures”](#the-founding-observation-seven-closures)
Before this layering existed, `internal/commands/wt/*.go` did three things at once: read the flags, run the flow itself, **and** hand the TUI closures that called back into the service. The TUI is forbidden from importing `service/`, so the command passed it functions instead:
| Closure injected into the TUI | Command | What it called back into |
| ----------------------------- | ------------------- | --------------------------------------------- |
| `SourceUpdate` | `create`, `extract` | `branch.Divergence` |
| `Target` | `create`, `extract` | `branch.Target` |
| `EnvFallback` | `create`, `extract` | `shared.EnvParentFallbackApplies` |
| `Check` | `clean` | `worktree.Check` |
| `ReparentPreview` | `clean` | `worktree.PlanCleanReparent` |
| `PlanPreview` | `sync` | `worktree.PlanSync` + `output.SprintSyncPlan` |
| `LoadFiles` | `extract` | `infra.ListModifiedFiles` |
The rule was respected and the architecture was still defeated: the service call happened on the TUI's goroutine, at the TUI's whim, with the command as a courier. Worse, the flow lived on both sides of that boundary — the dashboard could not replay it without duplicating it.
`flow/` **is allowed** to call the service. Those closures become hooks carried by the step declaration itself (`Skip`, `Build`, `Load`) and the courier disappears. That is the gain that justifies the refactor independently of the dashboard: `create` and `clean` inject nothing today.
The closures went with their command's migration: `checkout`'s `EnvFallback` and `Target` are now read by its recap step directly. `prune`'s `ReparentPreview` and `sync`'s `PlanPreview` both went with their migration — a flow calls `rules.FinalizePrunePlan` and `rules.SprintSyncPlan` directly, and `internal/tui/syncpicker` (the package `PlanPreview` was injected into) no longer exists. `extract`'s three went with its migration, along with `LoadFiles`: its files step loads them itself, and the create sub-flow it embedded in Bubbletea terms is now create's own steps, through `create.Embed`.
## The run module — a flow that asks nothing
[Section titled “The run module — a flow that asks nothing”](#the-run-module--a-flow-that-asks-nothing)
`internal/flow/runlogs` is the second shape a flow takes. `create` and `clean` ask questions and need a `Prompter`; a run has none to ask — it *reports*. So the seam is made of three types instead:
* **`runlogs.Board`** — the worktree's jobs as a surface reads them: `Jobs()` (a `JobView` per declared or running job), `Refresh()`, `Attach()` for a live `Stream`, and `History()` for what a job left in its log file. A surface never speaks to `service/process`.
* **`runlogs.Stream`** — one attached job: raw chunks in (escape sequences included, an emulator needs them untouched), keystrokes and a PTY resize out.
* **`runlogs.Run(ctx, RunParams)`** — a profile's start sequence, reporting each step to a `Sink` as an `Event`/`Phase`. It returns an `Outcome`, never an error: what a partial state is worth — an exit code, a report, a JSON entry — belongs to the surface. Cancelling `ctx` ends the *reporting*, not the jobs: that is what a detach is.
Three surfaces consume it, chosen by one pure rule (`rules.DecideRunSurface`, which needs a terminal, a human format and no `-d` before it picks the view):
| Surface | Who | What it does with the seam |
| ---------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------- |
| `internal/tui/runview` | a terminal | full screen, one VT-emulated pane per job, tmux-style focus; returns its recap for the command to frame |
| `output.RunPrinter` | `-d`, a pipe, CI | renders each `Event` as a line on stdout/stderr |
| `output.WriteRunOutcomeJSON` | `--output json` | the array of job results, with the failing job's `output` and `exit_code` |
Everything a job needs to know about *which* worktree it belongs to is resolved by the client and travels down the seam beside `WorkDir` and `LogDir`: `RunParams.Env` → `StartRequest.Env` → `process.Request.Env` → `cmd.Env`. It cannot be inherited — the daemon is global, outlives the command that forked it, and its own environment belongs to whichever worktree happened to start it. `service/worktree.EnsureOrdinal` is what gives the worktree the stable number those variables derive from, and `service/worktree.JobEnv`/`BranchEnv` assemble them; the daemon keeps the resolved map on the `ManagedJob` so the job's stop command runs in the same environment its start did.
`internal/commands/run/surface.go` is the whole wiring: open the seam, build the starter, switch on the rule. The one thing left in the command is `handleConcurrentJobs` — the question `run up` asks about another worktree's jobs. It is a `flow.Prompter` question in everything but name, and `runlogs` has no Prompter; it stays put until the `--exclusive`/`--parallel` axis is reopened, which worktree isolation may remove entirely.
## Worktree ports and the `.env` — a terminal transformation, not a source
[Section titled “Worktree ports and the .env — a terminal transformation, not a source”](#worktree-ports-and-the-env--a-terminal-transformation-not-a-source)
Two modules meet on the `.env` files, and the order they meet in is the whole design.
`internal/service/env` reconciles a worktree's `.env` against a **cascade of value sources** — the parent worktree, then main, then the committed template. `internal/rules/jobports.go` resolves the **host ports** a worktree binds: the base declared in `run.toml` plus that worktree's offset. A `[[env_port]]` link says a `.env` key carries one of those ports, whether alone (`DB_PORT=5432`) or buried in a URL (`DATABASE_URL=postgres://…@localhost:5432/app`).
The tempting move is to make the resolved port a fourth value source. It is wrong, and expensively so. The sources all hold *another* worktree's port — main's, or the parent's — so in `EnvModeRefresh` the key lands in `EnvKeyConflict` between two spellings of the same setting, and `--on-conflict overwrite` dutifully restores main's port, undoing the isolation on every run.
So the port is applied **after** the merge, once, in `settleEnvPorts`, and the diff is taught to compare *modulo the offset*:
| Piece | Where | What it does |
| ------------------------------------------ | ------- | ---------------------------------------------------------------------------------------------- |
| `rules.PlanEnvPorts` | pure | resolves every link against the value on disk; only a base found **exactly once** is rewritten |
| `rules.ReduceEnvPortValue` | pure | rewinds any worktree's port to the base, so `5442` and `5432` compare equal |
| `rules.DiffEnv` (`PortBases`, `PortBlock`) | pure | the single comparison site, in `classifyKey.differ` |
| `env.ApplyEnvPorts` | service | the write, after every file is reconciled |
Two consequences worth keeping:
* **The reduction is modular, not subtractive.** Under the `parent` strategy the source value comes from another worktree whose offset the reader never learns, so `ReduceEnvPortValue` looks for *a number of the shape `base + k×block`* rather than for one known value. A value with no such number, or with two, is left alone — reducing on a guess would hide a real conflict.
* **Every match is bounded by digit boundaries.** Without them base `5432` matches inside `54321` and the substitution silently corrupts the value, which is the exact failure the feature exists to prevent.
The cross-file check has to live outside `config.LoadRun`: that loader only ever sees `run.toml` and validates what `run.toml` can answer for alone. Whether a link names a configured env target needs `config.toml` too, so `rules.ValidateEnvPortTargets` is called where both are in hand — `service/worktree.ResolveEnvPorts`.
**Where the question is put, on a worktree being created.** `internal/flow/envports.Settle` runs after `worktree.Create` — it needs the files to exist — but it does not *decide* there. The decision is the worktree's **isolation**, a step of the run that provisions those files (`create.KeyIsolation`, `checkout.KeyIsolation`, `components.IsolationStep` for the wizard of `extract`), skipped whole when `rules.IsolationApplies` finds nothing in `run.toml` to isolate. `worktree.Create` records the answer in `meta.json` before any hook runs, since a hook reads the ports it decides.
**One choice, read by both halves.** Isolation is not a port-pass option; it is what the worktree *is*, and two readers act on it:
| Reader | Isolated | Verbatim |
| ---------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- |
| `service/worktree.ResolveEnvPorts` — every `.env` writer (create, extract, checkout, `wtm env`, the addressing switch) | links, identity and `[[env]]` values resolved and written | resolves to nothing: the file stays as copied |
| `service/worktree.BranchEnv` — every job and hook | `WTM_PORT_OFFSET = ordinal × block`, `COMPOSE_PROJECT_NAME` derived (the main's without its branch) | offset 0, `COMPOSE_PROJECT_NAME` left to the `.env`, `WTM_ISOLATION=verbatim` |
| `service/process.runNamespace` — the daemon | carves the worktree's namespace | carves nothing (read from `WTM_ISOLATION`: the daemon never reads metadata) |
They used to be separate: a "keep the ports" answer left the `.env` on its source's ports while the daemon still shifted the jobs, so a front read one port and its back bound another, and the worktree quietly talked to its source. Anything in between the two columns is incoherent by construction, which is why there is no third answer and no `Rewrite` flag any more. The cost of verbatim is that it shares its source's ports; `flow/run/up` measures that (`rules.PortClashes`) and turns the concurrency question into stop-the-other-or-don't-start rather than letting a bind fail. `wtm env --isolation` switches an existing worktree, and the interactive run asks the same question as a step that keeps the current isolation first, rather than skipping the port pass once.
## The event bus — the daemon relays, the flows speak, the jobs report
[Section titled “The event bus — the daemon relays, the flows speak, the jobs report”](#the-event-bus--the-daemon-relays-the-flows-speak-the-jobs-report)
`wtm events` and `wtm ui` hear every change to a worktree's identity, whoever made it. The pieces, from producer to consumer:
* **`flow.Publisher`**, a seam on `flow.Context`. A flow publishes right after the mutator succeeds, through `internal/flow/publish` (`Created`, `Updated`, `Relocated`, `Reparented`, `Removed`), which reads the worktree's identity (`worktree.Identity`) after the change. A removal captures the identity *before* (`publish.Capture`), since git has forgotten the worktree once it is gone. A nil publisher publishes nothing, and neither does one that is not `Listening()`: the identity is never read for a run no daemon would relay.
* **`service/events.Publisher`** implements the seam. It stamps the envelope (`v`, `ts`, `repo`) and hands the payload to `process.Publish`, which dials the daemon with a 250 ms budget, never starts it, and reports an error its caller drops: no daemon means no subscriber, and the next subscriber gets the state from its snapshot. Its `Listening()` is a plain dial, asked before every event and never cached, since a run may start the daemon halfway through.
* **The daemon** (`service/process`, `eventhub.go`) is a broker that never decodes the payload: an envelope `{repo, payload}` in, the same out to every subscriber whose filter holds `repo`. A subscriber that falls 256 events behind is disconnected, never waited for; a subscription keeps the daemon alive; `stop()` closes every subscription. `daemonblind` forbids `service/process` to import `service/events`, so the broker cannot become schema-aware by accident.
* **Job events are the one kind the daemon writes**, since only it sees a job end. `Manager` reports each transition (`JobTransition`: started, crashed, exited, stopped) to `ManagerParams.OnTransition`, and `daemonServer.publishJob` marshals a `domain.JobEvent` into the hub. It stays blind to git: the repository and correlation id come from the client in `Request.Origin` (built by `flow.Context.Origin()` from the publisher), the branch from the job's `WTM_BRANCH`, and the origin is persisted in `jobs.json` so an adopted job can still publish its stop. A job started without an origin publishes nothing. A stop sets `ManagedJob.stopping` before it signals, so the reaper does not read the exit as a crash.
* **`service/events.Watch`** subscribes **before** it reads the snapshot (`worktree.Identities`, plus the daemon's job list mapped by `rules.WorktreeJobs`), so a change made meanwhile waits in the subscription and arrives after `ready`. On EOF it backs off and starts over with a fresh snapshot.
The ordinal is part of the identity, which is why `worktree.EnsureOrdinal` left the env readers: `JobEnv`, `BranchEnv`, `ResolveEnvPorts` and the hook environment now read the ordinal and answer `ErrOrdinalUnallocated` when there is none, and `internal/flow/ordinal` allocates — `Retry` around a read that asked for it, `BeforeHooks` before a hook phase that would read it — and publishes `worktree.updated`. A reader outside any flow (the dashboard's addresses) simply shows nothing for a worktree no run has numbered yet.
## Where each flow runs
[Section titled “Where each flow runs”](#where-each-flow-runs)
Every worktree-mutating command goes through `flow/`; a new one does too — see [adding-a-mutation-command.md](/dev/adding-a-mutation-command/). The surfaces each one is wired into:
| Command | Flow lives in | Surfaces |
| ---------------- | ------------------------------------------------ | --------------------------------- |
| `create` | `internal/flow/create` | CLI wizard, unattended, dashboard |
| `clean` | `internal/flow/clean` | CLI wizard, unattended, dashboard |
| `reparent` | `internal/flow/reparent` | CLI wizard, unattended, dashboard |
| `prune` | `internal/flow/prune` | CLI wizard, unattended, dashboard |
| `sync` | `internal/flow/sync` | CLI wizard, unattended, dashboard |
| `relocate` | `internal/flow/relocate` | CLI wizard, unattended |
| `checkout` | `internal/flow/checkout` | CLI wizard, unattended |
| `env` | `internal/flow/env` | CLI wizard, unattended |
| `extract` | `internal/flow/extract`, create's steps embedded | CLI wizard, unattended |
| `fast-forward` | `internal/flow/fastforward` | CLI wizard, unattended, dashboard |
| the `run` module | `internal/flow/run/` | CLI, run view, dashboard |
# Writing the changelog
`CHANGELOG.md` is written in **English**, in the [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) shape, and follows [semver](https://semver.org). Each release section is also published verbatim as the GitHub release notes (`make release-notes`, run by the release workflow), so it is the first thing a new user reads about a version: write it for them, not for the reviewer of the PR.
## Template
[Section titled “Template”](#template)
Copy this under `## [Unreleased]` and keep only the sections that have entries, in this order.
```markdown
## [0.30.0] - 2026-11-02
One sentence on what this release is about, for someone deciding whether to upgrade.
### Highlights
- **`wtm events`**: stream every worktree change as JSON Lines, for editors, terminal plugins and agents. → [Event stream](https://github.com/LucasPcq/wtm/blob/main/docs/dev/docs/guide/events.md)
### Breaking
- **`wtm create --output json`** answers with an envelope: read `.results[0].path` instead of `.path`. → [Migrating to 0.29](https://github.com/LucasPcq/wtm/blob/main/docs/dev/docs/guide/migrating-to-0.29.md)
### Added
- **`wtm exec`** runs one command in several worktrees, in parallel, each with its own environment.
### Changed
- **`wtm ui`** picks up worktrees created elsewhere immediately instead of on its 20 s refresh.
### Fixed
- **`wtm relocate --to`** rewrites `base_path` even when no worktree has to move.
### Removed
- **`wtm pr`**: use `wtm checkout`.
```
## Rules
[Section titled “Rules”](#rules)
* **Curate, don't inventory.** A reader skims a release in thirty seconds to decide whether to upgrade: list what they would notice — a new command or flag, a behaviour that changed under them, a bug they may have hit. Wizard wording, alignment, message tweaks and small consistency fixes are left out, or folded into one closing bullet per area (`**`wtm env`**: clearer report, warnings on stderr, stricter flag checks.`). A release with more than \~15 bullets is an inventory: cut.
* **Short.** Aim for 20 words a bullet; the guide link carries the rest.
* **One bullet, one line, one change.** Bold the command, flag or file it is about, then say what the user gets, in the present tense. No "now", no "we", no internal names (packages, tickets, PR numbers).
* **Effect, not mechanism.** "`clean` refuses a locked worktree unless `--force`", not how the lock is detected. The detail belongs in the guide: end the bullet with `→ [Page](https://github.com/LucasPcq/wtm/blob/main/docs/dev/docs/guide/…)` when there is one.
* **Breaking is always its own section**, and every entry says what to do instead. A change that needs more than one line of instructions gets a `docs/guide/migrating-to-.md` page, linked from the bullet.
* **Highlights** is optional: one to three bullets for a release with a headline feature. A bullet listed there is not repeated under Added.
* **Internal-only changes are left out** (refactors, lint rules, tests) unless they change behaviour a user can see.
* **Section titles are fixed**: `Highlights`, `Breaking`, `Added`, `Changed`, `Fixed`, `Removed`. The release heading is `## [x.y.z] - YYYY-MM-DD`, with no title after it: the summary sentence carries the theme.
* Link references at the bottom of the file (`[0.29.0]: https://github.com/LucasPcq/wtm/releases/tag/v0.29.0`) keep the headings clickable; add one per release.
# internal/flow/ — how a command runs
A *flow* is everything a command does between "the flags are parsed" and "the result is printed": the questions it asks and in what order, which ones it may skip, the safety checks, the service calls, the phases it reports, the events it publishes. It is written once, in `internal/flow//`, and three surfaces run it: the CLI wizard, the unattended CLI, and the dashboard. Which command runs on which surface is the table in [architecture.md](/dev/architecture/#where-each-flow-runs); the recipe for a new one is [adding-a-mutation-command.md](/dev/adding-a-mutation-command/).
* [The shape of a flow](#the-shape-of-a-flow)
* [The three seams](#the-three-seams)
* [The step model](#the-step-model)
* [Unattended resolution and the two axes](#unattended-resolution-and-the-two-axes)
* [A flow that embeds another](#a-flow-that-embeds-another)
* [Surfaces and scheduling](#surfaces-and-scheduling)
* [Hook output](#hook-output)
* [Publishing what a flow changed](#publishing-what-a-flow-changed)
* [Testing a flow](#testing-a-flow)
* [Settled decisions](#settled-decisions)
* [Known gaps](#known-gaps)
## The shape of a flow
[Section titled “The shape of a flow”](#the-shape-of-a-flow)
One package per command, splitting the run from the questions it asks:
```plaintext
internal/flow/create/
create.go the run: Request, Outcome, Presenter, Params, Run, Operation
steps.go the session: the flow.Step declarations and the recap
```
The entry point is always the same shape — one struct parameter, one outcome, one error:
```go
type Params struct {
Context flow.Context // ProjectDir, StateDir, Config, Publisher
Request Request // what the surface already knows
Prompter flow.Prompter // who answers the questions
Presenter Presenter // where the phases go
}
func Run(params Params) (Outcome, error)
```
`Run` is a package-level function; behind it an unexported `createFlow` / `cleanFlow` struct holds the params so the step declarations can close over them.
**Errors are returned, never presented.** There is no `Presenter.Error`: on the CLI Cobra prints the error and `rules.ExitCode` sets the status; on the dashboard the caller puts it in the output panel. A user abort is not an error: the flow emits `flow.AbortedNotice` and returns `Outcome{Aborted: true}` with a `nil` error.
## The three seams
[Section titled “The three seams”](#the-three-seams)
### `flow.Prompter` — who answers
[Section titled “flow.Prompter — who answers”](#flowprompter--who-answers)
```go
type Prompter interface {
Ask(Session) (Answers, error)
Confirm(ConfirmParams) (bool, error)
Interactive() bool
}
type Session struct {
ErrLabel string // what the host calls the command if a step errors
Steps []Step
Presets Answers // values the request already carries
}
```
* **`Ask`** runs a whole question-and-recap sequence and returns every answer keyed by `Step.Key`, or `domain.ErrUserAborted`.
* **`Confirm`** is a standalone decision that only exists *after* an execution — a fast-forward that failed, a removal that needs `sudo`, extract's conflicts.
* **`Interactive`** is read for exactly two purposes: not offering a decision nobody can answer, and feeding a pure rule that takes it as input (`rules.DecidePush`). Any other use puts the bypass taxonomy back into the commands.
| Implementation | Where | `Ask` | `Confirm` | `Interactive()` |
| -------------------- | ------------------------------------ | ----------------------------- | --------------------------------- | --------------- |
| `flowui.Prompter` | `internal/tui/flowui` | `components.RunWizard` | `components.RunStandaloneConfirm` | `true` |
| `flow.Unattended` | `internal/flow/unattended.go` | resolves with no interaction | `false, nil` | `false` |
| `dashboard.prompter` | `internal/tui/dashboard/prompter.go` | a modal, over a reply channel | a one-question modal | `true` |
`Unattended` lives in `flow/` because it is the only implementation with no surface dependency, and it carries the bypass taxonomy, which must exist once.
### `flow.Presenter` — where the phases go
[Section titled “flow.Presenter — where the phases go”](#flowpresenter--where-the-phases-go)
```go
type Presenter interface {
Stage(StageParams) error // one unit of work under a progress indicator
HookPhase(HookPhaseParams) error // a titled hook phase and the sink it streams into
Notice(Notice) // concludes the run
Status(Notice) // one line inside an ongoing phase
}
```
A flow never frames, never animates and never picks a stream: it says *what phase this is*, the surface decides how it reads. Never report from inside a `Stage`: the spinner owns the stream and repaints over the line.
Each command widens it with its **typed conclusion**, plus per-item callbacks when it runs a batch:
internal/flow/create
```go
type Presenter interface {
flow.Presenter
BranchStarted(flow.Progress)
BranchCreated(domain.CreateResult)
BranchFailed(domain.BatchFailure)
Created(Outcome) error
}
```
The outcome carries data, never text. It is both an event and a return value because a conclusion sometimes has to be shown *during* the run (sync's plan before its push prompt) while the caller still needs the value for JSON and the exit code.
The run flows (`run up`, `run start`) add one more half, **`seam.Watcher`** (`internal/flow/run/seam`): `Sequence(SequenceParams) (runlogs.Outcomes, error)`. A start sequence cannot be reported through `Stage` — the surface has to be drawing before the first job is asked for — so the flow hands the surface the sequence and the surface calls it.
### `Request` — what the surface already knows
[Section titled “Request — what the surface already knows”](#request--what-the-surface-already-knows)
Declared by each flow package: the positional arguments and the flags that are business inputs.
internal/flow/clean
```go
type Request struct {
Branches []string
Force bool // the safety axis
ReparentChildren bool
BaseBranch string
AllowPrivileged bool // may this surface hand the terminal to sudo?
KeepData bool
}
```
It holds **no `--yes` and no `--output`**: the confirmation axis is the installed Prompter, the format is the surface's. `--force` belongs there — it is a business input the service consumes. So does a non-mutating mode such as `prune --dry-run` (see [Settled decisions](#settled-decisions)). `AllowPrivileged` is a surface capability expressed as an input: the CLI owns its terminal and can hand it to `sudo`; the dashboard holds it in alt-screen, leaves it `false` and names the way out (`domain.DashboardPrivilegedHintFmt`).
## The step model
[Section titled “The step model”](#the-step-model)
```go
type Step struct {
Kind StepKind
Key string // identifies the answer in Answers
Label string // the step's name in summaries, and the breadcrumb when there is no Title
Title string
Description string
Options []Option
Default string
Branches []domain.BranchCandidate // StepBranchSelect, with Pinned, PinnedSuffix, PinAbsent, Refresh
Validate func(value string) error
ValidateSet func(values []string) error // StepMultiSelect
ValidateEntry func(EntryCheck) error // StepTextList, through flow.CheckEntry
Skip func(Answers) (skip bool, reason string)
Build func(Answers) (StepContent, error) // re-derive content, synchronously
Load func(Answers) (StepContent, error) // same, with I/O, behind LoadingMessage
LoadingMessage string
Resolve func(Answers) (Answer, error) // the whole bypass taxonomy
Summarize func(Answer) string
Flag string // what an unattended run should pass instead
Arg bool // ...or that it is a positional
}
```
| Kind | Asks for | Answer in |
| ------------------ | ----------------------------------------------------------- | -------------- |
| `StepText` | a value (pre-filled by `StepContent.Default`) | `Value` |
| `StepSelect` | one option | `Value` |
| `StepBranchSelect` | a branch among candidates, one pinned | `Value` |
| `StepMultiSelect` | a set (options may arrive `Selected`, with a `Tag`/`Tone`) | `Values` |
| `StepReorder` | an order over its options | `Values` |
| `StepTextList` | names typed one by one | `Values` |
| `StepEnvResolve` | a decision per drifting `.env` key (`StepContent.EnvFiles`) | `EnvDecisions` |
| `StepRecap` | confirmation of the whole session | `Value` |
`flowui` renders every kind; the dashboard's modal renders all but `StepEnvResolve` and refuses an unknown kind (`domain.DashboardUnsupportedStepFmt`) rather than guessing. **A kind that is drawn must be read back**: a kind rendered but not read answers empty, and the flow writes that absence as if it were the answer. `TestEveryDrawableKindIsReadBack` (`internal/tui/flowui`) pins it. Adding a kind means teaching every surface that runs a flow using it.
**`StepContent`** is what may depend on earlier answers (`Title`, `Options`, `Default`, `Start`, `ExcludeBranches`, `Pinned`, `Banner`, `Blockers`, …). `flow.MergeContent` lays it over the step's static fields, and both surfaces read it through there. A `Load` runs while the step is on screen, so a slow source (`gh`, a worktree's changes) never blocks the wizard. A `Build` runs twice: once before the session's first question, with only the presets known, then again when its step is reached. **A `Build` that reads an earlier answer returns empty content while that answer is missing, never an error**: an error from that first pass aborts the whole session before anything is drawn. `ScriptedPrompter` makes the same first pass, so a flow test catches it.
**`Blockers`** are the safety refusals standing in the way of a step's dangerous option, each named on its own (`Key`, `Label`) instead of folded into prose. `rules.CleanBlockers` produces them, `internal/flow/clean/steps.go` attaches them to the delete step, and the dashboard renders each as a checkbox to tick before the dangerous option becomes submittable.
**Answers** are immutable and typed — no `any`:
```go
type Answer struct {
Value string
Values []string // set and order kinds
EnvDecisions []domain.EnvFileDecision // StepEnvResolve
Skipped bool
SkipReason string
Asked bool // false for a preset, a Resolve fallback, or a skip
}
```
`Answers.With` returns a copy; `Values` reads a single value as a set of one.
**`Presets` keep a flag from erasing a recap line.** A preset step is not asked, but the recap builder still reads it back, so `wtm create feat/x --from main` shows the same lines as the fully interactive run. A preset is never validated by its step: a flow that presets from flags validates them up front (create's `acceptRequested`, `Embedded.CheckBranch`). `Answered` is the converse — it says whether a human actually saw the question.
## Unattended resolution and the two axes
[Section titled “Unattended resolution and the two axes”](#unattended-resolution-and-the-two-axes)
`flow.Unattended.Ask` is the entire bypass taxonomy in one loop:
```go
answers := session.Presets
for _, step := range session.Steps {
if _, known := answers.Get(step.Key); known {
continue // a flag or a positional already answered it
}
if step.Skip != nil {
if skip, reason := step.Skip(answers); skip {
answers = answers.With(step.Key, Answer{Skipped: true, SkipReason: reason})
continue
}
}
if step.Resolve == nil {
return Answers{}, requiredErr(step) // refuse, naming step.Flag or the positional
}
answer, err := step.Resolve(answers)
if err != nil {
return Answers{}, err
}
answers = answers.With(step.Key, answer)
}
```
`Resolve` declares the three cases on the step, next to the question they answer:
| Case | The step declares | Example |
| -------------------------------------- | ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| 1. Decision with a safe default | `Resolve` returns an `Answer` | create's source-update step returns `ff` only under `--ff`, else `keep`; clean's reparent step returns `orphan`; every recap returns its confirm value |
| 2. Required selection, no safe default | `Resolve` returns an **error naming the flag** | create refuses to guess the parent of a pre-existing branch and names `--from`; sync's selection step names `--all` |
| 3. Interactive only | **no `Resolve`** | `Unattended` refuses with `requiredErr(step)`. It never falls back to a picker |
The default a `Resolve` returns is **never destructive**: `clean --yes` leaves children orphaned unless `--reparent-children`, `sync --yes` does not push, `extract --yes` aborts on conflict.
**`--force` never travels this path.** It is a `Request` field, the safety axis. `--force` alone does not imply `--yes` — the session still runs and asks to confirm, the refusals already lifted. `--yes` alone does not lift a refusal: `clean --yes` on a dirty worktree fails naming `--force` (`resolveDelete` in `internal/flow/clean/steps.go` runs the safety check while answering the step). Where the recap offers a dangerous option, the flag and the answer converge on one value: `request.Force || answers.Value(KeyDelete) == deleteForce`.
The command's only job on this axis is choosing the Prompter:
```go
interactive := rules.IsHumanFormat(format) && !yes && term.IsTerminal(int(os.Stdin.Fd()))
// ...
Prompter: shared.FlowPrompter(shared.FlowPrompterParams{Interactive: interactive}),
```
`--yes` is the only spelling of the confirmation axis — no `--non-interactive`, `init` and `run init` included. JSON mode requires `--yes`.
### Re-init completeness
[Section titled “Re-init completeness”](#re-init-completeness)
The write-side counterpart of "a flag never erases a recap line": a re-init step always shows the **complete** list of candidates, pre-filled from the config on disk when it speaks about them and from detection otherwise. A step whose answer may legitimately be empty is read as a pair `(value, asked)`: empty-and-asked withdraws, empty-and-not-asked leaves the proposal standing (`URLsAsked`, `ProfilesAsked`, `EnvLinksAsked`, `SelectionAsked` in `domain.InitProjectAnswers`, and `ScopesAsked`, see [shared-services.md](/dev/shared-services/)).
* The pre-fill reads the **existing config**, not detection, wherever the config has an opinion (`rules.ProposedScriptKind`, `rules.URLCandidatesFor`).
* A step that **removes** may only remove what it proposed: `rules.DeselectedJobs` never reaches a job written by `run job add`. Removal goes through `rules.RemoveJob`, which also strips profile entries, `[[env_port]]` and `[[env]]` links, `runs` and `touches`; a rename goes through `rules.RenameJobRefs`, which follows the same five.
`run init`'s services wizard edits structured rows no `StepKind` renders, so it is its own seam, `initrun.Wizard` (`internal/tui/inittui`, or `rules.AutoServicesAnswers` unattended).
## A flow that embeds another
[Section titled “A flow that embeds another”](#a-flow-that-embeds-another)
A flow that creates a worktree as part of its own run embeds create's questions instead of redeclaring them. `create.Embed(EmbedParams)` returns an `Embedded` whose `Steps()` are create's branch, source, isolation and source-update steps, each gated on the host's answers (`Applies`); `Presets()` and `CheckBranch()` carry and validate what the flags said; `Plan(answers)` is what the host's recap reads; `Provision` runs exactly what `create` runs for one branch. The session stays flat, so one recap covers both. `extract` is the host:
```mermaid
flowchart TD
A["extract.Run"] --> B["Ask: source worktree"]
B --> C["Ask: files — StepMultiSelect, Load from the source"]
C --> D["Ask: target — StepSelect, plus a create-new row"]
D --> E["Ask: create's steps, gated on create-new"]
E --> F["Ask: move or copy"]
F --> G["Ask: recap"]
G --> P{"create-new?"}
P -- yes --> Q["Embedded.Provision — fast-forward, worktree.Create, ports, hooks"]
P -- no --> H
Q --> H["conflicting files for this selection"]
H --> I{"conflicts?"}
I -- none --> M["extract"]
I -- "--on-conflict set" --> M
I -- "unattended" --> J["abort"]
I -- "interactive" --> L["Confirm: write markers or abort"]
L --> M
```
The on-conflict decision stays outside the session on purpose: the conflicts depend on the selection **and** on the disk — a target created a moment ago included — so it is a post-execution `Confirm`. A `--to` naming no worktree presets the target to create-new and create's branch step to its value, so the recap still reads every line back.
`create` itself is the canonical flow: validate the requested branches before asking anything (an unknown `--from`, a branch that is its own parent, one already checked out), `Ask`, run the accepted fast-forward (`Confirm` on failure), then per branch: `Stage` around `worktree.Create` with `SkipHooks: true`, publish `worktree.created`, settle the env ports, `HookPhase` for `on_create`, publish `worktree.provisioned`, and finally `Created(outcome)`. Hooks run as their own phase so their output never fights the creation spinner.
## Surfaces and scheduling
[Section titled “Surfaces and scheduling”](#surfaces-and-scheduling)
The CLI only picks the Prompter and a presenter over `shared.CLIPresenter`. The dashboard runs the flow on its own goroutine: the prompter posts the session with a reply channel and blocks on it, the presenter posts one `tea.Msg` per line or stage, and the model is only mutated on the UI goroutine.
### `flow.Operation`
[Section titled “flow.Operation”](#flowoperation)
```go
type Operation struct {
Kind string // domain.OpKindCreate, domain.OpKindClean, ...
Mode Mode // ModeBlocking | ModeBackground
TargetKey string // the answer naming the worktree this run holds
}
```
What a flow declares about how it is scheduled on a surface that runs several at once. `ModeBlocking` (`clean`) keeps the surface until the run ends; `ModeBackground` (`create`, whose hooks can run long) gives it back and locks its target instead. The target is known only once its step is answered, so the dashboard prompter posts `opTargetMsg` as soon as the session returns; run sessions answer with worktree **paths**, translated once to branches on receipt (`rules.BranchesForPaths`). An operation holds a stage per worktree, so each locked row shows its own progress. The CLI ignores all of it; `internal/tui/dashboard/ops.go` enforces it once.
### Handing the terminal over
[Section titled “Handing the terminal over”](#handing-the-terminal-over)
A flow whose surface is a second full-screen program — `run up` and `run logs` in the dashboard — goes through `tea.Exec` with a `tea.ExecCommand` that runs `runview` **in this process**, so its result comes back typed. Bubbletea restores the terminal around it but not the mouse tracking: a mouse-driven surface asks for it again (`internal/tui/dashboard/handoff.go`).
## Hook output
[Section titled “Hook output”](#hook-output)
A hook phase reports through `flow.HookSink`: `Output`, the raw stream, and `OnHook`, the `domain.HookBeat` of each hook starting and finishing. The flow asks the Presenter for a phase and hands the sink to the service; it never writes itself. `service/hooks` sends both stdout and stderr into `Output` (stderr is also kept for the failure beat) and renders the beats itself only when no `OnHook` was installed.
* **CLI**: `shared.DrawHookPhase`, the only place a hook phase is drawn, tees the stream into the phase's log (`HookPhaseParams.LogPath`) on every path; a terminal gets `output.HookView` (a bounded tail replaced by one result line per hook), anything else the raw stream. See [output.md](/dev/output/).
* **Dashboard**: `Output` is a `flow.LineWriter`, which emits one `OutputLineMsg` per `\n` (`Flush` for the trailing fragment); each beat is one more line. It need not be concurrency-safe: `RunHooks` serializes a hook's stdout and stderr copiers before the sink.
## Publishing what a flow changed
[Section titled “Publishing what a flow changed”](#publishing-what-a-flow-changed)
Every change to a worktree's identity is published from the flow that made it, never from the service, through `internal/flow/publish` (how the bus works: [architecture.md](/dev/architecture/#the-event-bus--the-daemon-relays-the-flows-speak-the-jobs-report)). The point is right after the mutator succeeded:
| Event | Published by |
| ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `worktree.created`, then `worktree.provisioned` | `create` (and `extract` through `Provision`), `checkout`: after the worktree exists (never on `AlreadyExists`), then after the `on_create` hooks with their error if any |
| `worktree.deprovisioned`, then `worktree.removed` | `teardown`: after the `on_clean` hooks, then after the removal, with the identity captured before it (`publish.Capture`) |
| `worktree.reparented` | `clean`, `prune`, `reparent` — one per moved child, partial results included (`publish.ReparentedAll`) |
| `worktree.relocated` / `worktree.updated` | `relocate`, after each `Move` / each `Adopt` |
| `worktree.updated` | `env` when the isolation changed; `flow/ordinal` the first time a worktree is numbered |
`tools/archlint` holds it: `chokepoint`'s table names each mutator's event, `emits` reports a flow package that calls a mutator without publishing its event or without a test recording what it publishes, and `metawriter` reports a `service/worktree` metadata writer the table does not list.
## Testing a flow
[Section titled “Testing a flow”](#testing-a-flow)
A flow is tested without a terminal, with the two doubles in `internal/testutil/flowtest`:
```go
prompter := &flowtest.ScriptedPrompter{Answers: map[string]string{
create.KeyBranch: "feat/x",
create.KeySource: "main",
create.KeyRecap: confirmCreate,
}}
recorder := &flowtest.Recorder{}
```
* **`ScriptedPrompter`** walks the session as a real host does — presets, `Skip`, `Build`/`Load`, `Validate`/`ValidateSet`/`ValidateEntry` — and answers from `Answers`, `Sets` (set kinds) or `EnvDecisions`. It records `Asked` (`AskedKeys()` for a one-line assertion) and the `Content` each step produced, so a test can assert on what the user would have seen. A step with nothing scripted is an error, so a new question cannot slip in unnoticed. Like `flowui`, it first builds every step from the presets alone and fails on a `Build` that errors there. `Abort` makes `Ask` return `ErrUserAborted`; `Confirmed` answers every `Confirm`.
* **`Recorder`** implements `flow.Presenter`, collecting `Stages`, `Hooks`, `Beats`, `Notices` and `Statuses`, and runs `Work()` and `Run(sink)` for real. It is also a `flow.Publisher`: set it as the `Context`'s `Publisher` and `Published` / `PublishedTypes()` hold every event (`Unheard` simulates nobody listening). The `emits` rule requires such a test in every package that calls a mutator.
The typed conclusion is not part of `Recorder`; a test embeds it and adds the command's methods:
```go
type recorder struct {
*flowtest.Recorder
outcome Outcome
}
func (r *recorder) Created(o Outcome) error { r.outcome = o; return nil }
```
For the unattended path, `flow.Unattended{}` **is** the double (`internal/flow/unattended_test.go`). The CLI-level tests in `internal/commands/wt` (`create_noninteractive_test.go`, `integration_test.go`, `prune_test.go`) pin what a user observes: do not edit one to make a refactor pass.
## Settled decisions
[Section titled “Settled decisions”](#settled-decisions)
* **A non-mutating mode is a business input.** `prune --dry-run` is `prune.Request.DryRun`, and `Run` returns the plan before asking or touching anything — not a second `Plan()` entry point. Any rule reading `Interactive()` must take the mode too: `rules.PruneClassifyForce` takes `DryRun`, since a surface may install an interactive Prompter for a preview.
* **`--force` is OR'd with the recap's answer** (`prune`, `clean`): a plain "Yes" after `--force` never drops the unsafe worktrees again. Pinned by `TestForceSurvivesAPlainConfirmation`.
* **The reparent service functions stay separate.** `worktree.ReparentBatch` validates acyclicity because the user chooses the new parent; `worktree.ApplyReparents` reattaches children to their grandparent, which cannot close a cycle. Both already funnel into `setSourceBranch`; merging them would only add behaviour flags.
* **The dashboard offers sync's `--keep-conflict`** like the CLI, and names per branch where to finish (`domain.SyncKeepConflictHintFmt`).
* **A pre-check is not a preset** (`sync.Request.Precheck`): it only says which boxes arrive checked. `Sync this worktree` pre-checks the row's ancestry (`rules.SyncAncestry`), never its descendants. No dashboard entry for a dry run: the recap is the plan, and closing the modal changes nothing.
* **No terminal, no `--yes`, no `--dry-run` → refuse** (`domain.SyncNeedsTerminal`, as in `prune`): `Unattended` would otherwise mutate. Sync's `interactive` omits `!dryRun`, since `--dry-run` on a TTY still picks what to preview.
## Known gaps
[Section titled “Known gaps”](#known-gaps)
Deliberately open, not to be fixed opportunistically:
* `clean --force` without a TTY resolves the delete step without any safety check (`resolveDelete` returns early on `Force`) and without a confirmation.
* `flow.Context` duplicates `shared.ConfigResult`, which imports cobra and so cannot be reused as is.
* `flow.Step` carries kind-specific fields (`Branches`, `Pinned`, `Refresh`, `ValidateSet`, `ValidateEntry`, …) on every kind.
* `busyReason("")` only sees blocking runs, so a `ModeBackground` run holding a worktree does not stop a `ModeBlocking` run with no target (batch reparent, `prune`) from acting on it.
* When every prune match is skipped, the run reports an empty result instead of the skips that explain it (pinned by `TestPruneAllUnsafeReportsNothing`).
* `internal/flow/run/job` and `internal/flow/run/profile` are near-clones over two unrelated config types; sharing them would mean generics for no reader's benefit.
# The gates — make lint and what it holds
`make lint` is the mechanical half of `CLAUDE.md`. Every rule in it exists because a reviewer would otherwise have to hold the layer rules in their head on every PR, and the ones nobody holds are the ones that drift. Read this before adding a rule, an exception, or arguing that a check is wrong.
```plaintext
make lint # fmt + vet + arch + dead + staticcheck — all gating
make test # go test ./... -race -count=1
make dupl # clone report, informative only
make dead-strict # deadcode without -test: code only a test still reaches, informative only
```
| Check | Catches | Why staticcheck cannot |
| ------------------------- | -------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
| `fmt` | unformatted files | it is not a formatter, and this fails rather than rewrites: a formatting fix belongs in the commit that caused it |
| `vet` | the stdlib's own suspicions | — |
| `arch` (`tools/archlint`) | the project's own rules, below | it checks a package against itself, and knows nothing about this project's layers |
| `dead` (`deadcode`) | functions no path reaches, **test paths included** | it reports the unused *within* a package; a function exported and called by nobody is invisible to it |
| `staticcheck` | the rest | — |
## `tools/archlint`
[Section titled “tools/archlint”](#toolsarchlint)
Each rule is a `golang.org/x/tools/go/analysis` Analyzer resolved by type — an aliased import names the same object as a plain one. A finding prints `file:line:col: [rule] why`.
| Rule | Checks |
| --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| `layers` | the import graph of [architecture.md](/dev/architecture/#who-may-call-whom), from the `layers` table |
| `domain` | `internal/domain` declares types, errors and constants only — no function |
| `servicedag` | each `service/x → service/y` import against `serviceEdges` |
| `daemonblind` | the daemon — `service/process` and `service/proxy` — imports nothing that runs git and only allow-listed `infra/` |
| `styles` | only `internal/styles` instantiates a `lipgloss.Style` |
| `typeassert` | a type assertion without comma-ok |
| `yesflag` | a command that reads the interactive gate (`shared.Interactive`) without offering `--yes` (`shared.AddYesFlag`) |
| `chokepoint` | a service mutator called from anywhere but `internal/flow/` |
| `metawriter` | an exported function of `service/worktree` that reaches `writeMetadata`/`purgeState` is in the mutators table — the table is complete by construction |
| `emits` | a `flow/` package calling a mutator publishes that mutator's event, and has a test recording it (`flowtest.Recorder`) |
| `publish` | `process.Publish` is called from `service/events` only; the `flow` seam's `Publish` from `internal/flow/` only |
| `glyph`, `tuistyle`, `mutedline`, `fontcover` | the output vocabulary, below |
### The output vocabulary
[Section titled “The output vocabulary”](#the-output-vocabulary)
The glyph table was written down and the surface diverged anyway — sixty commands, five renderings of "nothing to do", `!` alone rendered as a filled chip. Four rules hold the parts a table cannot say (the reasoning is in [output.md](/dev/output/#three-rules-that-make-the-vocabulary-hold)). The first three run only over the layers that put glyphs on a screen (`output`, `styles`, `tui`): `rules/` and `service/` are left out because `=` and `!` are ordinary bytes to an env parser or a pnpm workspace pattern, and a check that cannot tell those apart is one people work around. `fontcover` runs over every string, since the runes it is about are declared in `domain`.
| Rule | Catches |
| ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `glyph` | a vocabulary rune written as a literal — `"✓"`, `"!"`, `"→"` … — instead of its `domain` constant, which is how a seventh glyph appears and how an existing one takes a second meaning |
| `tuistyle` | `styles.Badge*` or `styles.Dashboard*` used from `internal/output`: a badge is a widget, and its padding made an attention line two columns wider than the failure line under it |
| `mutedline` | `Message(w, styles.Muted.Render(x))` — a bare line muted whole, which is the `=` register with its glyph filed off |
| `fontcover` | a non-letter rune missing from common monospace fonts, in any string of `internal/` — the terminal borrows it from a wider fallback face and it eats the space after it. The allowed set, `fontSafe`, was measured over thirteen fonts; `fontLegacy` holds the runes that predate the rule and may only shrink |
### Adding a rule
[Section titled “Adding a rule”](#adding-a-rule)
`tools/archlint` is where a new architectural rule goes. Its `layers` table is the dependency graph written once, so a new dependency between two layers is a deliberate edit to that table rather than something that lands unnoticed. Each rule is tested with `analysistest` against its own fixtures, one `txtar` archive per rule (`tools/archlint/testdata/.txtar`, a section per file named by its import path). The driver loads the packages once per target system (`darwin`, `linux`, `freebsd`), and a test checks that together they compile every file of the tree, so a build-constrained file is never skipped; `-warn`, `.archlint-migrating` and `fontLegacy` are applied after every analyzer has run. Adding a rule there is cheaper than adding a paragraph to `CLAUDE.md`, and it is the only kind of rule that survives.
A rule belongs in `make lint` when breaking it breaks something — a layer, a refusal, a surface that can no longer run a flow. A rule about how code reads belongs in review.
## Exceptions
[Section titled “Exceptions”](#exceptions)
* **`.deadcode-ignore`** — one regex per line **with its reason**: code reachable by a route the analysis cannot follow (so far, a method satisfying an interface asserted on an `any`). Anything unlisted fails.
* **`.archlint-migrating`** — ` ` lines for what predates a rule; they report as `(migrating)` without failing. **It may only shrink**, and that is checked: each entry — like each rune of `fontLegacy` — records how many sites it covers, one site more fails `make lint`, and a count higher than needed is reported as a note to lower it. A new entry is a decision to take knowingly and belongs in a ticket, never a way to get a commit past the linter. It holds no `chokepoint` entry today: a new one is a regression, not a migration.
`make dupl` is deliberately outside `lint`: a clone is a judgement call. Two parallel families over unrelated types — `flow/run/job` and `flow/run/profile` — read better duplicated than behind a generic, so the report informs a review rather than gating one.
## The pre-commit hook
[Section titled “The pre-commit hook”](#the-pre-commit-hook)
`.claude/hooks/pre-commit-gates.sh` runs `go mod tidy` (on a copy) and `make lint` on every `git commit`, as a Claude Code `PreToolUse` hook, and blocks the commit when either fails. It reads the same Makefile a human does — which is the whole reason the gates live there. It does **not** run the tests: at \~70 s that is a gate people disable, and the CI and the `build-validator` subagent run them. `WTM_SKIP_GATES=1 git commit …` gets past it for the case where the gate is itself wrong.
## Considered and left out
[Section titled “Considered and left out”](#considered-and-left-out)
So it is not re-proposed:
| Rule | Measured | Why not |
| ---------------------------------------------------------- | -------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Structs for 2+ inputs (`CLAUDE.md` §2) | 546 functions at 2+ non-carrier inputs, 17 at 4+ | A count cannot tell a related pair from a carrier pair. A gate at 2 fires on most of `output/`; one at 4 still fires on a syscall wrapper whose arity is the ABI, and on list widgets whose `renderRow` gains nothing from a struct. Encoding a rule that cannot tell the cases apart teaches people to work around the linter. It stays a review rule |
| Comment density (`CLAUDE.md` §8) | — | The ceiling is met as easily by deleting the comments that earn their place as the ones that do not, so the number measures the wrong thing. The rule itself needs rethinking before anything can check it |
| A `label value` format string hand-aligning its own column | 1 match in `domain` (`DetailReviewDecisionFmt`), and it is a dashboard fragment, not a block | The regex that finds a hand-aligned label finds the one legitimate use too. `Announce` owning the alignment is the fix; a gate over it would teach people to space their labels differently rather than to use the helper |
# The output layer
How a `wtm` command speaks. `CLAUDE.md` carries the rules in short form; this is the reasoning behind them, and the reference to read before adding a command or changing what one prints.
## The one question
[Section titled “The one question”](#the-one-question)
A block earns its place when it changes what the reader does next. Not when it is true, not when it was expensive to compute, not when it is interesting — when it changes what they do. Everything below follows from that.
The question has a corollary that is easy to get backwards, so it is worth stating on its own: **success contracts, an anomaly expands.** A pass that did exactly what was asked is a count. A refusal, a conflict, a link matching nothing is named one by one, because the reader can only fix the one they can see. Giving the nominal path as much room as the actionable one is what makes a CLI read as noise — and it is the shape most reports drift into, because listing what happened is easier than deciding what matters.
Two more consequences, in the order they bite:
**Detail belongs to the command whose subject it is.** Ports are the subject of `wtm env` and `wtm run init`; in `create` and `extract` they are a side effect, so they collapse to a count on the recap's env line. A reader who wants the values runs the command that is about them, or opens the file the run just wrote. The file is the record; the command says how many and where.
**A successful run has a fixed shape.** What makes output feel bloated is not its size but its variance: a conclusion whose height depends on what happened can never be recognised at a glance, so it has to be read. `wtm create` is the same six lines whether it settled three ports or thirty.
## Two streams, three registers
[Section titled “Two streams, three registers”](#two-streams-three-registers)
stdout is the result — what a script would read. stderr is everything about getting there.
Three registers, and only the third may grow with what happened:
| Register | Where | Lives for | Examples |
| ------------- | ------ | -------------------- | ----------------------------------------------- |
| **Result** | stdout | the scrollback | the framed conclusion, a table, a state readout |
| **Progress** | stderr | until it is replaced | spinners, a hook's tail, a job's raw output |
| **Attention** | stderr | the scrollback | warnings, refusals, anomalies, callouts |
Progress is erased, so it is never barred and never framed — the bar marks what stays. Attention is the only register allowed one line per item.
The corollary is that **everything a run says while it is still running, and keeps, is one block**. A migrated command's status lines and hook phases used to write straight to stderr, which left them the only human output outside the bar; `shared.OpenBlock` puts them inside one, and the conclusion is a second block on stdout — which is what "exactly once" already allows.
**A surface remembers where its last block left the cursor**, and that is why the bookkeeping lives in `output` rather than in the presenter. The blank closing a block and the blank opening the next are the same line on screen, so a caller deciding whether to open one cannot answer from what it did itself: the frame beside it is written by code that never sees it — `run up`'s own frame around a job's output is the case that made this necessary. `FrameStart` therefore writes no blank on a surface already at a boundary, writes the separator on one whose block is still open, and `BlockOpen` is what `OpenBlock` and `syncPresenter.section` both read. stdout and stderr are **one** surface when both are the same terminal: the reader sees one column of blocks, whichever stream wrote them.
The consequence for a flow: **never report from inside a `Stage`**. A spinner owns the stream while it runs, so a line written under it is repainted over — and the block it opened is then marked open with nothing on screen to show for it. Collect what happened and report it after the stage returns (`internal/flow/run/up/up.go`, `clearOthers`).
## The frame
[Section titled “The frame”](#the-frame)
Every human conclusion is framed **exactly once**, with `output.Frame` or — for a command writing across two streams — the `FrameStart`/`FrameEnd` pair. The frame owns two things at once: the single blank line above and below the block, and the accent bar down its left edge.
```go
output.Frame(cmd.OutOrStdout(), func(w io.Writer) {
output.Success(w, "Created worktree feat/x")
})
```
The body writes to the writer it is **handed**, never to the one `Frame` was given. That is what puts the bar on every line, in the one place the padding is already applied. A formatter therefore emits a raw body: no leading blank, no trailing blank, `output.Blank` only as a genuine separator between sections inside the block.
A streaming pair wraps its own body writer: `output.Barred(w)`. When a command writes across two streams — `sync`'s plan on stderr, its recap on stdout — there is one rule rather than two mechanisms: **every section opens with exactly one blank line on the stream it is about to write to**, the first of them being the frame's leading blank, and `FrameEnd` closes. Same call, same output, one mechanism.
JSON (`--output json`) and machine output (shell-eval: `resolve` success, `shell-init`, `run url`, `run export`) are never framed and therefore never barred. They emit flush. A command routes on `rules.IsHumanFormat(format)`. `wtm events` is the one stream with no frame at all: it never ends, so there is no block to close, and each human line carries the bar on its own.
### The bar
[Section titled “The bar”](#the-bar)
`┃`, in column zero — left of everything else the CLI prints, which is what makes it a marker rather than one more indent. In a terminal running `git`, `pnpm` and `docker`, it says *this block is wtm speaking*.
It goes on a **terminal only** (`output.IsTerminal`). A pipe, a CI log or a redirection gets the bare text, so `wtm create | tee log` stays clean and a grep over that log never has to know about the bar.
## The four levels, and what each is for
[Section titled “The four levels, and what each is for”](#the-four-levels-and-what-each-is-for)
A visual system holds by its contrasts, not by its repetitions. If everything is marked, nothing is.
| Level | For | Where |
| ---------------------------------------------- | ------------------------- | ---------------------------------------------- |
| **A flat line** | an act you just performed | `create`, `clean`, `checkout`, `run job add` |
| **A table** | an inventory you consult | `list`, `tree`, `run ps`, `run list` |
| **A pill-titled block** (`styles.RenderRecap`) | a state you come back to | `init`, `run up`, `run down` |
| **A callout** (`output.Callout`, bordered) | something still to act on | port isolation, proxy hints, withheld bindings |
The pill is the contrast element and stays rare. A one-line conclusion in a box is five lines of chrome around one line of content — that is the reductio, and it is why the box is not the standard.
## The two shapes of a conclusion
[Section titled “The two shapes of a conclusion”](#the-two-shapes-of-a-conclusion)
**Form A — the act.** One subject:
```plaintext
┃ ✓ Created worktree feat/x
┃
┃ from main
┃ env main · 4 ports settled (offset +10)
┃ path .worktrees/feat-x
┃
┃ → wtm go feat/x
```
A `✓` headline, nought to three aligned fields, at most one next step. Budget: 8 lines.
**Form B — the readout.** Several objects:
```plaintext
┃ ✓ 3 pruned · 1 skipped
┃ feat/a, feat/b, feat/c
┃
┃ ! docs/api skipped — open PR #42
```
`rules.Tally` counts, zero counts dropped (the CLI and the dashboard share it); then **one line per exception only**, never per success. Budget: 6 lines plus the exceptions.
One nuance that is not a matter of taste: a **destructive** run names what it destroyed — knowing what is gone is actionable — but on one line, because the picker and the recap have already shown that list twice. A non-destructive run counts.
### A run's addresses
[Section titled “A run's addresses”](#a-runs-addresses)
A run is the one conclusion that lists addresses, and it does it once: each job line carries a single fragment (`rules.ReachSummary` — the URL, `:5432`, `3 urls`, `6 ports`), and the full list is the **Where to reach it** block (`rules.ReachBlock`) the run ends on, the run view shows behind `a`, and its recap keeps. A port list on a job line is how `docker-compose` came to take 160 columns; see [run-addressing.md](/dev/run-addressing/#where-to-reach-it--one-model-for-every-surface).
## The glyph vocabulary
[Section titled “The glyph vocabulary”](#the-glyph-vocabulary)
Exhaustive. One glyph per line, at its head; never two vocabularies in one block.
| Glyph | Means | Helper |
| ----- | ---------------------------------- | ------------------ |
| `✓` | changed state, and it worked | `output.Success` |
| `=` | was already in the desired state | `output.Unchanged` |
| `~` | an existing thing was replaced | `output.Update` |
| `!` | needs attention; the run continues | `output.Warning` |
| `✗` | failed | `output.Error` |
| `›` | in progress — ephemeral only | `output.Loading` |
| `→` | what to do next | `output.NextStep` |
The runes live in `domain` (`GlyphSuccess`, `GlyphAttention`, …), not as literals in `output/`, so a seventh cannot be introduced by typing one.
### Three rules that make the vocabulary hold
[Section titled “Three rules that make the vocabulary hold”](#three-rules-that-make-the-vocabulary-hold)
The table above was already written, and the surface diverged anyway — because it fixes the rune and says nothing about the rest of the row. These are the parts that were missing.
**1. The glyph carries the only colour on its line.** The message beside it stays in the terminal's own foreground. Green, yellow and red are a margin of signals down the left of a block, not a property of the text: a reader scans the margin and reads the words. Two registers are the exception, and for one reason — `=` and `›` mute their line **whole**, because there the line itself is the non-event.
Which kills `output.Danger`, and with it the third failure register. `!` is something left to do, `✗` is a failure; a refusal and a crash are the same register, and which of the two it was belongs in the sentence. The old boundary was decided file by file — `sync` and `relocate` called a blockage `Danger`, `fast-forward` called the same idea `Warning`.
**2. Every glyph is one column.** `!` used to render as a filled chip carrying its own padding, so an attention line sat two columns wider — and read louder — than the failure line under it. `internal/output/env.go` had already left the vocabulary over this, rendering a bare `!` because the badge "made the rows wander a column apart". Badges belong to the TUI, where a chip is a widget; a line of CLI output is text. One column is also a property of the **font**, not only of the rune: a glyph the terminal's font lacks is drawn from a fallback face, often wider, and overflows onto the space after it. `↻` did exactly that under JetBrains Mono (Ghostty's default), which is why the update glyph is `~`. `make lint` holds this through `archlint`'s `fontcover` rule: a non-letter rune in any string of `internal/` must belong to `fontSafe`, measured as present in thirteen common monospace fonts (box drawing and block elements are exempt, terminals draw those themselves). The runes that predated the rule — `▸`, `⚠`, `●`… — are listed in `fontLegacy`, report as migrating, and that list may only shrink.
**3. `Muted` has exactly two jobs, and detail is not one of them.**
* **Chrome**: what is never content — a field's label, a table's header row, a tree's connectors, the note glossing a `NextStep` command.
* **A non-event, whole**: the `=` and `›` lines above.
Secondary detail is expressed by **indentation, not by colour**. A branch list under a count, the lines of a failure's captured output, an address under a job: they are content, they sit one indent in, and they keep the foreground. Muting them was the third job, and it is the one that made the same class of information read at three different densities depending on the command.
These three are checked by `make lint` (`tools/archlint`, rules `glyph`, `tuistyle`, `mutedline` — see [lint.md](/dev/lint/#the-output-vocabulary)) over `output/`, `styles/` and `tui/` — the layers that put glyphs on a screen. What a linter cannot check it cannot hold, and the first version of this document proved that a table alone does not survive sixty commands.
### What follows from the three rules
[Section titled “What follows from the three rules”](#what-follows-from-the-three-rules)
**"Nothing to do" is `=`, everywhere** — and "everywhere" includes the places that are not a conclusion. An **empty inventory** is a non-event: `output.UnchangedLine` is `Unchanged` for a formatter that returns a body, so an empty table takes the same glyph as a command that found nothing to do. So does **backing out**: an abort changed nothing, and it is `=` with one wording (`domain.AbortedMessage`) rather than a bare sentence in four. It still exits `19` (`ExitCodeCancelled`): `CLIPresenter.Notice` marks the command when it draws that line, and the root ends the process on the mark, so a shell chaining `wtm create x && wtm go x` stops there while the dashboard, which never reads an exit code, is left alone.
A **state readout** may not hide a non-event as a field value either. `not running` and `not installed` are the `=` register; a `Section` line is where the detail goes, under a conclusion, never instead of one.
**A conclusion is not optional.** Every human command ends on exactly one, in one of the four shapes of the section above. A readout with no line over it makes the reader infer the outcome from a field.
**A hint is `output.NextStep`, everywhere**: one arrow, one bold command, an optional muted note. A reader learns once where to look for what to do next. Prose telling someone what to run — backticked in a `Message`, muted in a box, inline after a `›` — is the same information in a place nobody looks twice.
**The four block helpers, arbitrated.** They overlapped for as long as nothing said which was which, so two sibling readouts ended up aligned two different ways and two sibling previews titled two different ways.
| Helper | Shape | For |
| -------------- | ---------------------------------------------------- | --------------------------------------------------------------------- |
| `SectionTitle` | the bold title alone | a caller that draws its own body — a table, a stream, glyphed lines |
| `Announce` | title + `label value` rows, labels aligned and muted | anything a reader looks *up*: a plan before a picker, a state readout |
| `Section` | title + indented free lines | a script, a file's contents, a listing |
| `Callout` | a bordered box | **only** something the reader still has to act on |
`flow.Notice` carries that last distinction across the seam: `NoticeNote` is what the reader has nothing to do about — a property of the machine, or of the file that was just written — and takes `Section`; a warning carrying lines is what wtm declined to do, and keeps the border. The port pass is both at once: the links it left alone are bordered, `Addresses carry the proxy's port` is not — and that one is said by `wtm env` and `wtm run addressing`, whose subject it is, never by a creation (`rules.EnvPortNoticesOnCreate`).
The alignment belongs to `Announce`, never to the wording: a format string spelling `"State %s"` hand-aligns one block against nothing, and its sibling three files away picks a different column.
**A `--dry-run` answers on stdout.** A preview is what the caller asked for, so it is the result and not a diagnostic. A plan shown *before* a real run — `sync`'s — is a preamble and stays on stderr.
**"Exactly once" counts uninterrupted blocks, not frames.** A command frames each block of human output once; a prompt between two blocks makes two, because there are two blocks. So does a split across streams — `run down`'s failures on stderr and its recap on stdout. What the rule forbids is a second frame around the same block, or a helper emitting its own padding inside one.
**A diff is not a register.** `wtm env` prints `+` / `!` / `−` per key — every key under `--check`, and under an apply only what it left for the reader, what it did being one counted line per file (`rules.EnvFileTally`) — and that is deliberate: those runes describe a *change to a line of a file*, not the state of a run, and they read as a column down the left of a file block rather than as the head of a conclusion. It is the one vocabulary outside the table, it is confined to `output/env.go`, and adding a second one is a decision to argue for here first.
**The status palette names states, never identities.** `run logs` used to cycle green and yellow across job prefixes, so in the one command whose body is job output, yellow meant "job 3". A label saying where a line came from is chrome.
**`output.Message` — the bare line, carrying no status — is not for a conclusion.** It is the most-called helper in the tree, and that is the symptom it names: when nothing in the vocabulary fits, people fall back to a line that says nothing. A conclusion line carries a glyph or is an aligned field.
## `--quiet`
[Section titled “--quiet”](#--quiet)
The output axis, and nothing else. It replaces the command's writers with `io.Discard`, so every framed conclusion, notice and progress line goes nowhere — while the error and the exit code still arrive, because `Execute` prints those to `os.Stderr` rather than through the command.
It never touches a machine contract: `--output json` still emits its document, and a command whose stdout **is** the answer declares `domain.AnnotationMachineOutput` and is left alone. Asking for less noise is not asking for less answer.
The corollary is easy to lose. `domain.ErrAborted` means *the command already printed its own report*, and that stops being true the moment the report went to `io.Discard`: a run that exits non-zero having written nothing to either stream cannot be told from one that hung. So `--quiet` records that it silenced the writers, `Execute` prints the error even for `ErrAborted` when it did, and a site returning that sentinel over a refusal wraps its cause (`fmt.Errorf("%w: %s", domain.ErrAborted, …)`) so there is something to print. The same rule reaches the hook runner: with no reporter installed nobody has drawn the hook's result line, so `service/hooks` names the failing command in the error rather than leaving it anonymous.
It is orthogonal to `--yes`, like the two bypass axes: `--quiet` still asks, `--yes` still reports, and a script that wants neither passes both.
## The machine contract
[Section titled “The machine contract”](#the-machine-contract)
`--output json` is read by programs, so its shape is decided once and never follows what happened. Four rules hold for the `run` module, and a new document follows them rather than its neighbour:
* **One shape per command.** A command that acts on worktrees answers with an array of per-worktree documents even for one worktree (`run up`, `run down`, `run stop`, `run logs`); a single-subject command answers with one object (`run start`, `run job|profile add|edit|rm`). A shape that changed with the arity made every caller branch on how many worktrees it had named.
* **A worktree is `branch` + `path`**, both, always — never `worktree` or `work_dir`. `domain.WorktreeRef` is the type when nothing else rides along. A job object is keyed `name`; anything pointing at a job from another object calls it `job`.
* **`status` never claims an act that did not happen.** A stop that found nothing up is `not_running`, never `stopped`; a start that found the service already up is `already_running`, never `started`; a shared job let go of is `released`.
* **Exit codes are part of the document.** `rules.ExitCode` maps the sentinels: `2` for a command line refused before the command ran (`cmd/usage.go` wraps cobra's flag and argument errors, an unknown `--output`, and `--output json` without `--yes` on a command marked by `shared.RequireYesInJSON`, in `domain.ErrUsage`; a refusal a command makes itself — a positional that does not parse, `--all` with a name — goes through `rules.Usage`, and belongs in the command's `Args` when it reads only the arguments), `14` for a job or profile run.toml does not declare (`ErrJobNotFound`, `ErrProfileNotFound`), checked by `target.RequireDeclared` before a flow asks anything or wakes the daemon.
A document that is a protocol elsewhere is not reused for output: `domain.JobInfo` is what the daemon speaks, so `run ps` writes `domain.RunningJob`, and renaming a key there never needs a daemon restart. `run list`, `run export` and `run import` are the exception to the naming rule on purpose — they are run.toml as JSON, and keep its keys (`job`, `profile`, `env_port`).
## Showing without keeping
[Section titled “Showing without keeping”](#showing-without-keeping)
A hook that runs for forty seconds has to be visible while it runs — silence reads as a hang — and must not survive in a scrollback nobody rereads. `output.HookView` is the shape: a bounded tail redrawn in place, erased and replaced by one `✓ (12.4s)` line, and the tail kept on screen when the hook failed.
It applies to a terminal this process may repaint. A pipe, a CI log or `--output json` gets the raw stream, unconditionally.
Both paths go through one function, `commands/shared.DrawHookPhase`, and it is one function on purpose: two paths to it — the migrated commands through `CLIPresenter`, `extract` and `checkout` through a helper of their own, since gone — drifted apart once, and a hook has to read the same whichever command ran it. It owns the log rather than the view, opening `/hooks/-.log` and teeing the raw stream into it on **every** path: the run whose output the reader could not watch is exactly the one whose record has to survive. And it always hands the sink a real writer — the command's own — because a sink left nil falls back to `os.Stderr` in the runner, which is how a hook finds its way onto a terminal that asked for `--quiet`.
A hook's own bytes are never barred, for the same reason progress is not: `barWriter` re-marks the row after every carriage return, so a bar drawn over a redrawing progress line lands on top of its content. The rule reaches the run module too — `output.RunPrinter` bars the lines it composes and writes a job's chunks through untouched.
What the phase *keeps* is barred, and that is the whole of the distinction: `HookView` composes every line it prints — the tail included — so those go through the bar, while the cursor moves of `clear()` go to the raw stream. A bar written before one lands on the row the cursor is about to leave, survives the erase below it, and leaves every repaint one column off. `HookViewParams.Bar` is what says which writer a line takes. `DrawHookPhase` joins the run's block itself rather than leaving that to each caller: `extract` holds a presenter that may already have opened one for the port pass, and a phase that decided for itself drew an unbarred block beside a barred one.
The seam that makes it possible is worth copying for anything similar: `service/hooks` reports `domain.HookBeat` values through `flow.HookSink` — the raw output *and* the beat of each hook starting and finishing — so the surface decides what to draw and the service formats only the fallback for a caller that installed no reporter.
## Addresses are clickable
[Section titled “Addresses are clickable”](#addresses-are-clickable)
Every job address a person reads can be followed with a click, and there are two mechanisms because there are two kinds of surface.
* **Text a command prints** wraps each address in an OSC-8 link with `rules.LinkURLs`, on a terminal only (`output.IsTerminal`). A pipe, a CI log, `--output json` and machine output (`run url`, meant for `$(…)`) never receive the escape. Link **after** padding or truncating: the escape has no width, and a column measured in bytes or runes would push everything after it. `run ps` pads its ADDRESS column first, then links it.
* **A full-screen surface holds the mouse**, so the terminal never sees a plain click on a link. The run view and the dashboard open the address under a click themselves with `components.URLAt`, which reads it off the frame they last drew, so an address is clickable wherever it lands without declaring a zone. A truncated address (ending in `…`) is never followed. The run view also draws OSC-8 links (Params.Hyperlinks) for the terminal's modifier-click; the dashboard does not, because bubblezone measures the escape as text and would shift every zone on that row.
## Adding a command
[Section titled “Adding a command”](#adding-a-command)
1. Pick the form: an act (A) or a readout (B). If it is neither, it is a table or a machine contract.
2. Frame once. Write to the writer the frame hands you.
3. For each block you are about to add, answer the one question. If it does not change what the reader does next, it is a count.
4. Use the glyph vocabulary. If none fits, the line is probably accounting.
5. If stdout is the command's contract, annotate it with `domain.AnnotationMachineOutput`.
# Named URLs — the vocabulary
Three different things were being called "the proxy": the server that makes names resolve, the OS rule that removes a port from those names, and the port either of them happens to use. They are separate mechanisms with separate failure modes, and confusing them makes every message about them unreadable. This page fixes the words. Code, constants and CLI copy use these and no synonyms.
## The seven terms
[Section titled “The seven terms”](#the-seven-terms)
| Term | What it is | Where it lives |
| -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------- |
| **the proxy** | The reverse proxy the daemon runs. It listens on one port and routes by `Host` header to the job answering under that name. **This is what makes names exist at all.** On by default; `[proxy] enabled = false` in the global config switches it off | `internal/service/proxy` |
| **bind port** | What the proxy actually listens on: `11080`, or the next free port when that one is taken (the R7 fallback) | `rules.ProxyPort`, then the daemon's own fallback |
| **privileged redirection** | The OS rule `wtm run proxy install` installs — on macOS a launchd agent binding `:80` and relaying to the bind port. **It only removes the port suffix from a URL.** It unlocks nothing | `internal/service/proxy/redirect*.go` |
| **public port** | What a URL *announces*, as opposed to what anything binds: nothing (i.e. `:80`) when the redirection is live, the bind port otherwise | `rules.PublicPort` |
| **route host** | `...localhost` — the name a published job answers under | `rules.RouteHost` |
| **origin** | `scheme://host[:port]` — the thing a browser sends in `Origin:` and CORS compares. The port is part of an origin; it is **not** part of a cookie's origin | `rules.JobOrigin` |
| **addressing** | `ports` or `names`: which of the two an `[[env_port]]` link writes into a `.env` value, and which of the two a run announces | `rules.EffectiveAddressing`, `rules.RunProxyPort` |
## What follows from the distinction
[Section titled “What follows from the distinction”](#what-follows-from-the-distinction)
**The proxy makes names work. The redirection makes them pretty.** `*.localhost` resolves to `127.0.0.1` natively (RFC 6761), but resolving is not answering — without the proxy listening, a name gives `connection refused`. Without the redirection, the same name works perfectly, carrying `:11080`.
| State | What a `.env` holds | Works? |
| --------------------------------------- | ------------------------------------------------ | ------------------ |
| proxy on, redirection **not** installed | `http://api-dev.feat-x.monorepo.localhost:11080` | yes |
| proxy on, redirection installed | `http://api-dev.feat-x.monorepo.localhost` | yes |
| proxy off (`enabled = false`) | `http://localhost:4011` | yes, without names |
**A port in the URL costs nothing that matters.** CORS compares whole origin strings, so as long as both sides carry the same one — port included — it passes. And cookie isolation, the reason named URLs exist, keys on the **host** and ignores the port entirely: `feat-x` and `feat-y` are separate jars whether or not `:11080` is there.
**A run announces what the `.env` files spell.** Under `ports` a run is opened with no proxy port (`rules.RunProxyPort`, through `seam.ProxyPortsFor`): it registers no route, and `run up`, `run ps`, `run url`, `run open` and the run view's reach pane hand out `http://localhost:` — while the machine's proxy keeps running for any other project. Announcing the name there would point the reader at an origin the app's CORS settings and API urls know nothing of.
**Addressing is a project setting, the proxy is a machine setting.** `run.toml` says what the project wants; the global config says what this machine can do. When the machine cannot honour it — proxy off, no public port — ports are written and a notice says so. Writing a name nothing serves would produce a syntactically perfect, dead value.
## Addressing in `run.toml`
[Section titled “Addressing in run.toml”](#addressing-in-runtoml)
```toml
addressing = "names" # the default — omit it
```
Under `names`, an `[[env_port]]` link writes the job's full origin instead of its port number, but **only when two conditions hold at once**:
1. the job it names **publishes a url**, and that url publishes the very port the link follows;
2. the value in the `.env` **has the shape of a URL**.
Both are load-bearing. The first excludes Postgres — `DATABASE_URL → docker-compose.POSTGRES_PORT` has no name and never will, since the proxy only speaks HTTP. The second excludes the binding keys: `apps/api/.env: PORT → api-dev.PORT` names a published job and must stay a bare number. Neither condition alone is enough.
```plaintext
apps/api/.env PORT=4001 → 4011 (bare number)
apps/web/.env VITE_API_URL → http://api-dev.feat-x.monorepo.localhost:11080
CORS_ORIGIN → http://web-dev.feat-x.monorepo.localhost:11080
DATABASE_URL → …@localhost:5442/db (no name)
```
`addressing = "ports"` is the escape hatch, and it is a real inverse: a value wtm wrote as an origin is recognised as such and rendered back to `http://localhost:`.
`wtm run addressing` is how it is switched (`flow/run/addressing`, `Switch`). Its two questions are steps of one session — the mode, then whether to settle the worktrees — so the wizard can go back from the second to the first. The second reads every worktree's plan under the mode just picked, **before** run.toml is written: `ResolveEnvPortsParams.Addressing` overrides the file's, and the count is cached per mode so going back does not reread every `.env`. The settle itself is `envports.Settle`, the pass `create` and `extract` run, so the three cannot write different values.
Which worktrees it takes is the one place the main checkout is treated apart, and the rule is `rules.BulkSettlesMain`: a pass over every worktree brings main back to ports, and never moves it onto names. See the next section for why main is left alone; the asymmetry is what keeps the two commands consistent with it:
| Command | Linked worktrees | Main |
| -------------------------- | ------------------- | -------------------------------------------------------------------------------------- |
| `wtm env ` | aligned on the mode | kept on the mode its `.env` spells — `--addressing` or the wizard's step is the choice |
| `wtm run addressing ports` | settled | settled: ports is the state main has without wtm |
| `wtm run addressing names` | settled | left as is, and said so — `wtm env main --addressing names` is its own decision |
Without the return leg, `wtm run addressing ports` after a `wtm env main --addressing names` would leave main on names under a project that says ports, and silently: the drift warning only reads a project on names. The condition lives in the command's choice of worktrees, not in the pass — the plan and the drift reading stay free of any main-shaped condition, as below.
## Recognising wtm's own writing
[Section titled “Recognising wtm's own writing”](#recognising-wtms-own-writing)
A value already carrying a route host is recognised **structurally** — the authority matches `...localhost` — rather than remembered in a state file. One primitive then answers four questions that would otherwise each need their own mechanism:
* a second `wtm env` recomputes the same origin, so nothing is rewritten twice;
* a `.env` copied from main or a parent carries that worktree's segment, which is corrected to this one;
* installing the redirection after the fact changes the public port, and the next pass drops the `:11080`;
* switching to `addressing = "ports"` knows which values were wtm's to undo.
A state file would be more precise on paper and worse in practice: it diverges the moment somebody edits a `.env` by hand.
## Dev servers and the `Host` header
[Section titled “Dev servers and the Host header”](#dev-servers-and-the-host-header)
A named URL reaches the job with a `Host` the dev server has never seen, and some servers refuse one they do not recognise. Two of them matter here, and they do not need the same thing:
* **Next** refuses it unless the host is listed in `allowedDevOrigins`. `wtm run up` reports the missing entry when it serves a job whose directory holds a `next.config.*` without one (`rules.NeedsDevOrigins`, `rules.DevOriginsPattern`).
* **Vite** needs nothing. Its host check allows `localhost` and **every** `.localhost` subdomain outright, before `server.allowedHosts` is even consulted — verified in Vite 8.2.2, `isHostAllowedInternal`. There is nothing for wtm to detect or report.
Anything else is a report from a user, not a guess from us: the presence of a config file is the signal, never a framework inferred from a command line.
## The trade the mode makes
[Section titled “The trade the mode makes”](#the-trade-the-mode-makes)
Under `names`, **the named URL becomes the only working entrance.** Opening `localhost:5183` directly sends an `Origin` the API no longer knows, and CORS blocks it. `wtm run url` and `wtm run open` hand out the right link; a bookmark on the raw port stops working. That is the cost of having two worktrees stay logged in at the same time.
## The main checkout is not a special case
[Section titled “The main checkout is not a special case”](#the-main-checkout-is-not-a-special-case)
The spec that introduced `names` said the main checkout stays on ports. **No code enforces that, and none should.** Nothing in the port pass tests the ordinal, the base branch, or whether a checkout is the main one: `worktree.List` comes from `git worktree list`, which includes it, so the pass writes named origins on main like anywhere else when it is asked to. The port substitution is the identity there (offset 0); the origin rewrite is not — it replaces an authority, which has nothing to do with the offset.
What "main stays on ports" really means is that **no command provisions it**: `create` and `extract` write the new worktree, and there is no such event for main. That is a gap in the lifecycle, not a guard in the code — so the answer is a warning, not a refusal.
The one main-shaped condition is in `flow/env`, and it is about the *question*, not the pass: reconciling main's keys is something a user runs often, moving it onto names is a decision taken once, and folding the second into the first made every `wtm env main` an addressing switch. So `wtm env` reads which mode main's `.env` spells (`rules.MainAddressing`, from the plans of both modes: `AddressedByPort` on the names plan, and whether the two plans write any value differently) and passes it to the pass as `ResolveEnvPortsParams.Addressing`, unless `--addressing` or the wizard's addressing step asked for the other one. `rules.ValidateEnvAddressing` keeps the override to main: a linked worktree follows `run.toml`.
`rules.PendingOriginRewrites` counts what a `wtm env` on a worktree would still move onto a named origin, `rules.AddressingDriftLine`/`Lines` phrase it, `worktree.EnvPortPlanFor` computes the plan without applying it (the same one `wtm env` writes, so the two cannot disagree), and `flow/run/addressing` is what the run flows read. They hand the lines to the **surface**, which renders them once where they can be seen: a band in the run view, a callout beside a stream, nothing at all on a machine run. A notice printed after the view reaches a reader who has already followed the URL.
Two things the count is deliberately not. It is not "the .env holds ports": a value already carrying a named origin whose public port went stale — every worktree, the moment `proxy install` moves 11080 to 80 — is pending too, and equally broken, which is why the wording says *out of step* rather than naming ports. And it is not "the app is broken": wtm sees only the keys declared as `[[env_port]]` links, and cannot know whether the app makes a cross-origin call at all. The reading names no worktree in particular: a linked worktree whose port pass was declined is in the same state, and main is only the one that is there by construction.
Do not add a main-shaped condition here.
**The address a surface hands out is the name, whatever the `.env` spells.** It used to follow the file — a worktree still on ports was handed `http://localhost:` everywhere — and the result was that the one URL a reader came for disappeared on the checkout they use most, with the reason in a callout at the very bottom. Now every surface hands out the published name, and a worktree whose `.env` is out of step gets **one** `!` line naming the command that aligns it (`rules.AddressingDriftLine`; `rules.AddressedByPort` only picks which of its two sentences). The trade is explicit: until that command runs, a cross-origin call made through the name is refused, and the line is what says so. `--raw` on `run url` / `run open` still gives the port.
## Where to reach it — one model for every surface
[Section titled “Where to reach it — one model for every surface”](#where-to-reach-it--one-model-for-every-surface)
A run says where each job is reached in **one** place, `rules.ReachBlock` over `domain.ReachEntry` (`internal/rules/reach.go`): the URLs first — a runner's by the apps it holds — then the jobs reached by port, their ports named (`REDIS_PORT` → `redis`) and wrapped in balanced columns, a shared job's namespace beside it. Every surface renders that block at its own density, so none of them decides on its own what to show:
| Surface | Line / title | The block |
| ---------------------------- | ----------------------------------------------------------------------------------- | ----------------------------------------------------- |
| stream (`run up -d`, a pipe) | one fragment per job — the URL, `:5432`, `3 urls`, `6 ports` (`rules.ReachSummary`) | concludes the run, before the `!` lines and the hints |
| run view | pane title `job · status · fragment` | behind `a`, and in the recap left on exit |
| `run ps` | `ADDRESS` column, `WORKTREE` as the branch | — |
A shared service a linked worktree only holds runs in main, and every surface says so the same way: `postgres joined, running in main` on its line, a **Shared, running in main** section in the block (`rules.ReachSections`), a `shared` line above it in the run view's job list, and a pane title naming where it runs rather than the worktree holding it. Stopping the worktree leaves those running — which is the one thing the reader has to be able to see.
## Related
[Section titled “Related”](#related)
* `internal/rules/envorigins.go` — the whole origin surgery, pure and testable
* `internal/rules/envports.go` — the port substitution it sits beside
* [architecture.md](/dev/architecture/) — which layer may call which
# Shared services — one instance for the repository, one namespace per worktree
The `run` module gives each worktree its own stack. That is the point, and it is also what makes a real monorepo expensive: two worktrees of a project with four postgres containers and a keycloak mean eight postgres and two JVMs. Some of those services have no reason to be duplicated, and `scope = "shared"` is how a project says so.
The measurement that motivated it is worth keeping: on the machine that prompted the work, CPU was 93% idle while memory sat at `587M unused` with `3230M compressor`. The constraint is memory, and the only variable wtm controls is the number of instances.
## The principle
[Section titled “The principle”](#the-principle)
wtm cannot know every provider. It provides **surfaces**: it runs a command at the right moment and injects the right environment, giving the command access to what only wtm knows — the worktree's ordinal, its ports, its URLs. What that command does to a postgres or a keycloak is the project's business.
Any question this document leaves open is settled with that sentence. If the answer requires wtm to learn what a realm is, it is the wrong answer.
## Where a shared job runs
[Section titled “Where a shared job runs”](#where-a-shared-job-runs)
`jobKey(name, workDir)` is untouched. The sharing is entirely in the choice of work dir.
The real service runs in the **main checkout** — the worktree `domain.GitWorktree.IsMain` designates — registered under `:`: a real PTY, real ports, a real output hub. It is the only directory guaranteed to live as long as the repository, and being at ordinal 0 it takes **no port offset**, so a declared `5432` is the `5432` it binds. That stability is what lets a namespace's `env` write its URL literally.
Every other worktree posts a claim under `:`, with status `domain.JobStatusJoined` (`joined`; a daemon or index from before the rename says `attached`, read as `joined`): no PID, no PTY, no hub, no log file. It is a pointer, and it is the reference count. **The job table is the count**, so there is no second registry to keep in step, and the statestore already persists it — a claim carries `Joined` in its record so it comes back from the index as what it is rather than as a foreground service the daemon had lost.
A claim owns no stream: attaching the run view to one from any worktree reaches the one output there is.
A claim is dropped when its service is no longer up, and `Manager.Adopt` is where that is enforced: a daemon killed without running a handler may leave a shared foreground service to be reaped at the next start-up, and a claim outliving it would be a reference count on nothing — the next worktree would be told its service is already running. The pass runs on every adoption, not only after a reap, because the same hole opens whenever the real job's record is gone and the claims' are not (a deleted main checkout, for one).
Releasing a claim stops the service only once no worktree holds it. Two worktrees racing to start the same service both succeed — `run up --all` fans out, and losing that race is not a failure. A shared job whose main checkout the client could not resolve is **refused**, never run once per worktree.
## The namespace
[Section titled “The namespace”](#the-namespace)
`[job.namespace]` is the worktree's namespace in the shared service, and it is **singular**. It does not name an object of the provider: four keycloak realms are one namespace, whose internal shape belongs to the create script. That is the direct consequence of the principle above, and it is what keeps a list of realms or databases out of the TOML.
Two mechanisms, not two spellings of one:
* `{worktree}` and `{ordinal}` are **wtm's own substitution into data** — `namespace.name` and `namespace.env` are never executed, and wtm fills them in before anything runs;
* `$WTM_WORKTREE`, `$WTM_ORDINAL`, `$WTM_NAMESPACE` are **environment variables**, expanded by `/bin/sh` when `create` or `remove` runs.
`$WTM_WORKTREE` in a `name` would expand to nothing: no shell ever sees a name. Making wtm expand it there would be worse — it would look like shell syntax while only three variables worked, so `$HOME` would silently fail beside it. The step therefore offers each row only what that row takes.
`attach` and `detach` run with the **worktree's whole resolved environment** — ports and URLs included. That is what makes keycloak possible at all: a realm's `redirectUris` point at the fronts of the worktree asking for it, and the script needs those URLs. Without that access the design would handle postgres and leave keycloak stranded.
`create` runs on **every** start of the shared service, not once — wtm keeps no ledger of having run it, and a ledger would be wrong the moment the data went away behind wtm's back (`docker compose down -v`). So the command must be safe to run again: create the namespace if it is absent, do nothing if it is there. That is the whole contract, and it is stated where the command is written — the schema, the `run init` step, and the failure message.
It is retried within `domain.NamespaceCreateTimeout`: the service it talks to was started moments ago, so a first refusal means "postgres is not accepting connections yet" far more often than it means the command is wrong. The budget is what stops a genuinely wrong command retrying for ever. It cannot tell a refusal that will pass from one that never will — which is exactly why the idempotence is the command's job and not wtm's guess.
The daemon is what runs it, and a daemon that considered itself idle while doing so used to exit under its own handler: a shared service launches detached, so nothing is left `Running` to keep it alive. The idle watcher counts connections in flight beside the running jobs.
An absent `[job.namespace]` is a valid answer: shared for good, one instance and one set of data.
### Reaching the app: the `[[env]]` link
[Section titled “Reaching the app: the \[\[env\]\] link”](#reaching-the-app-the-env-link)
Carving a namespace out is half the work. The app has to be told which namespace is its own, and that is not something a port can say.
`[[env_port]]` rewrites **the port inside** a value and leaves the rest alone, which is what lets a password live in a `.env` and never in `run.toml`. It can express "the shared keycloak answers here" and nothing else. A realm name is opaque — no number, no shape, nothing to anchor a substitution on.
So a second table, `[[env]]`, writes a key's **whole** value from a template:
```toml
[[env_port]] # the shared instance: one address for all
file = "apps/web/.env"
key = "KEYCLOAK_URL"
job = "keycloak"
port = "KEYCLOAK_PORT"
[[env]] # the namespace: one per worktree
file = "apps/web/.env"
key = "KEYCLOAK_REALM"
job = "keycloak"
value = "{namespace}"
```
That is the line between the two, and it is worth stating once: **`[[env_port]]` says where the service answers, `[[env]]` says which namespace in it this worktree holds.** A shared service has one address for every worktree — its published host carries no worktree segment and its port takes no offset — so the first is not per-worktree at all.
The vocabulary is closed: `{namespace}`, `{port.NAME}`, `{origin}`, `{worktree}`, `{ordinal}`. Anything else is refused when `run.toml` is read, against a stand-in worktree, so a typo is caught for every worktree at once rather than the first time one is created. `{port.NAME}` goes through `rules.ResolvedPort`, the one place that answers what a declared port becomes in a worktree — deriving it a second time here is exactly how a report once said 5432 while the file was written 5452.
A resolved link becomes a `domain.EnvOwnedEntry`, which is the mechanism that already existed for `COMPOSE_PROJECT_NAME`: wtm owns the line, plans it, reports it when it changed, and takes it out of the reconciliation's verdict. A key wtm writes in full differs from its source by construction, so calling it a conflict would have every `wtm env` offer to undo the isolation it had just set up.
A key may not be written by both tables. They are not complementary — an `[[env]]` value writes its own port when it needs one (`postgresql://app:app@localhost:{port.POSTGRES_PORT}/{namespace}`), so a key both claim is a line to delete rather than a merge order to invent. It is refused at load, naming both.
Nothing here needs the daemon: `{namespace}` is `name` with `{worktree}` substituted, known without running anything. So the links settle at the same moments the port links do — when a worktree is created, and on `wtm env` or a `sync` reconciliation.
### `run init` and the keys it cannot detect
[Section titled “run init and the keys it cannot detect”](#run-init-and-the-keys-it-cannot-detect)
A port is detectable: the key is named `PORT` or `*_PORT`, the value is a number, and it matches a port a job declares. Three signs agreeing. `KEYCLOAK_REALM=myapp` has none of them — a realm name is an opaque word — so **the step asks**, and every managed key is a row. Filtering the list would hide the only key the reader wanted.
Two things narrow it without wtm pretending to know what a realm is:
* **A key whose value carries a port the service binds is its address, never its namespace.** That is the `[[env_port]]` table's business, and the signal is structural rather than a guess about the key's name. It works on a first init, where no link exists yet. A key an `[[env_port]]` already writes is excluded for the same reason.
* **A key whose name starts with the job's own name is pre-checked** — `KEYCLOAK_*` beside a job called `keycloak`. That is a deduction from a name the user chose, not knowledge of the service.
On the pair that motivated the design, the two rules split it exactly: `KEYCLOAK_URL` holds `8080` and stays with the port table, `KEYCLOAK_REALM` is pre-checked and becomes an `[[env]]` link. Everything else is offered, unchecked, with the value it holds today beside it.
The one proposal wtm makes for a template is `{namespace}` — the same decision as `app_{worktree}` for the name, and as the two commands it proposes nothing for. Editing a template links its row: editing is asking for it to be written.
The step **migrates rather than stacks**. Marking a key that an `[[env_port]]` link already writes takes that link off, since the two are refused together at load — a wizard that wrote both would produce a config wtm then refuses to read, which is the worst outcome a wizard can have. The pruning happens once both tables are complete: the init pipeline settles the values and then appends more port links, so the last word is taken after that append.
Re-init is symmetric like every other step (`EnvValuesAsked`, the same `(value, asked)` pair): unchecking every row withdraws every link the step offered, a run that never asked leaves run.toml standing, and a link on a file the step never showed — one `config.toml` no longer configures — survives untouched. A step may only remove what it proposed.
### Knowing a namespace exists
[Section titled “Knowing a namespace exists”](#knowing-a-namespace-exists)
A claim goes with a `run stop`, so it cannot be what tells `clean` there is a database to drop. The worktree's own `meta.json` carries `namespaces`: the shared services it has actually carved a namespace out of, recorded the moment each shared service reports started — not at the end of the sequence, since a `run up` interrupted after the create would otherwise leave a database nothing records. A write that fails is a warning on the run (`PhaseWarning`), never silence: a namespace nobody wrote down is one no clean will drop. It lives there because the file is removed with the worktree it describes, and because both wrong answers are bad — giving back a namespace that was never created runs a `DROP DATABASE` on nothing, and missing one leaks a database on every iteration.
A worktree created and thrown away without ever starting the stack therefore owes nothing.
### Writing the two commands
[Section titled “Writing the two commands”](#writing-the-two-commands)
`run init` asks. After the scope step, a step lists three rows per shared service — its name, its `create`, its `remove` — and only the name carries a proposal. wtm has nothing honest to say about the other two: a recipe for postgres would guess the port variable, the user, the host and whether `psql` is even on this machine, and a pre-filled command that is accepted and then fails inside the retry budget reads as a wtm bug rather than as a line to write. It is the same reason wtm does not guess the port flag of a framework.
What wtm *does* know it shows, while the field is open — grouped by where it comes from, since one run-on line stops being readable as soon as a job declares more than one port:
```plaintext
available
worktree $WTM_NAMESPACE $WTM_WORKTREE $WTM_ORDINAL
ports $CRM_DB_PORT $CRM_ADMIN_PORT
```
The first row is the same everywhere; the second is the ports **this job** declares, under the names it declares them by. A long group wraps under its own first variable rather than repeating its label. Both an inline command and the path to a script are accepted — both are a `/bin/sh` line run in the worktree.
An empty `create` is an answer, not an omission: the service is then shared outright, data included.
Outside `run init`, `run job add` and `run job edit` declare the same thing: `--scope shared|worktree`, `--namespace-name`, `--namespace-create`, `--namespace-remove` and `--namespace-env KEY=VALUE`, and their form asks the same fields — the namespace ones only once the scope is shared. There an empty *name* is the "shared outright" answer, so a named namespace must have a `create`: the loader refuses a block with one and not the other (`rules.HasNamespace`), and every write goes through the same validation (`runconfig.Save`).
### Starting a namespace from main's data
[Section titled “Starting a namespace from main's data”](#starting-a-namespace-from-mains-data)
What makes isolation feel expensive is rarely the namespace itself — it is an empty database to migrate and seed, a realm to rebuild by hand. That cost belongs in `create`, not in wtm: **clone the data main uses instead of creating an empty namespace.** The worktree then starts where main is, and still owns its copy — nothing it migrates or resets reaches main, which is exactly what sharing main's data outright could not promise.
For Postgres it is one statement, guarded because `create` runs on **every** start of the shared service:
```sh
#!/bin/sh
# scripts/db-worktree-add.sh — the [job.namespace] create of a shared postgres.
set -e
psql="psql -h localhost -p $POSTGRES_PORT -U postgres -v ON_ERROR_STOP=1"
exists=$($psql -tAc "SELECT 1 FROM pg_database WHERE datname = '$WTM_NAMESPACE'")
[ "$exists" = 1 ] && exit 0
$psql -c "CREATE DATABASE \"$WTM_NAMESPACE\" TEMPLATE app"
```
Three things to know about it:
* **`app` is the database main's `.env` actually names**, not the namespace wtm would give main: main predates wtm, and no `[[env]]` value is ever written into it. `ResolveEnvPorts` drops the main checkout's `[[env]]` links (they are plain keys there, compared like any other) and turns them into repairs instead: a key still holding exactly the value wtm would compute for main — what 0.29 and earlier wrote on a `wtm env main` — goes back to the template's (`rules.MainEnvValueRepairs`), through the owned-value pass, so `--check` counts it and the report names it. A value the user edited since is not wtm's and is left alone.
* **`TEMPLATE` refuses a source with open connections.** Stop main's backend while the clone runs, or trade the instant copy for `pg_dump app | psql "$WTM_NAMESPACE"` after a `CREATE DATABASE`, which copies around them.
* `$POSTGRES_PORT` is there because the command gets the job's own ports under the names it declares them by, next to `$WTM_NAMESPACE`, `$WTM_WORKTREE` and `$WTM_ORDINAL`.
A Keycloak realm follows the same shape: export main's realm, rewrite its name to `$WTM_NAMESPACE`, import it — skipped when the realm already exists.
## A job that changes someone else's data
[Section titled “A job that changes someone else's data”](#a-job-that-changes-someone-elses-data)
A namespace protects a worktree's data only as long as the jobs it runs write to that namespace. Two cases break that on purpose: a **verbatim** worktree, whose `.env` names its source's databases, and a shared service with **no** `[job.namespace]`, which holds one set of data for every worktree. A profile running `orm:reset` there resets someone else's database.
wtm cannot see that from a command, so the job says it: `touches = ["postgres"]` names the services whose data it changes. `run init` asks it in its *Data tasks* step, after the runners: one row per task, cycling through the services that hold data (the shared ones and the compose stacks) under the names the configuration being built gives them. `rules.TouchChoices` pre-sets a row only from what run.toml already says, or when the task's name carries a data verb and shares a word with exactly one service — `orm:billing:reset` and `postgres-billing`; anything less certain is left on none for the reader. The step reuses the runner list, which already cycles one job name per row. `rules.ForeignDataRisks` reads those declarations against the worktree's isolation — from `WTM_ISOLATION`, the same answer the daemon acts on — and `internal/flow/run/foreigndata` stops `run up` and `run start` before the job starts:
| Surface | What happens |
| --------------------- | ------------------------------------------------------------------------------------ |
| a terminal | *Run them anyway* / *Don't start*, naming each job, the service and whose data it is |
| `--yes`, JSON, no TTY | refused, naming `--force` and `wtm env --isolation isolated` |
| `--force` | let through without a question — the safety axis, never implied by `--yes` |
The main checkout is never stopped: it owns its data, and every other checkout is either carved beside it or copied from it. A job with no `touches` is never stopped either — the guard reads what the config declares and nothing else, so a project that declares nothing keeps the behaviour it had.
## Stopping is not destroying
[Section titled “Stopping is not destroying”](#stopping-is-not-destroying)
`run stop` and `run down` never run `detach`. A `run down` that dropped a database would make the command unusable.
The detach belongs to `clean` and `prune`, and `flow/teardown` fixes where it sits in a removal. Per worktree: (1) the worktree's own jobs are stopped **and checked gone** — its claims stay — and a job still up refuses the removal unless `--force`; (2) the `on_clean` hooks run; (3) git removes the worktree; (4) only then is the namespace dropped; (5) the claims are released. Any failure before (4) leaves the data where it was: dropping first, as clean once did, lost the database of a worktree whose hook then failed, and the next `run up` recreated it empty. Stopping the jobs first is also what lets the drop through at all — an API still connected to its database is exactly what `DROP DATABASE` refuses. The claims go last because releasing the last one stops the service, which could then take nothing back; `teardown.Batch` releases them all once every drop is done, since one worktree's claim may be what keeps the service up for the next one's. It runs the whole sequence on a worktree before starting the next; `prune` stops at the first that fails, `clean` keeps going.
The drop runs from the project directory, the worktree's own being gone, with the environment read while it existed. Its `remove` command is bounded by `domain.NamespaceRemoveTimeout` — a drop waiting on a lock nobody releases would otherwise hold the clean for ever — and a drop past it, or refused by a service that is up, is owed like one whose service is down, with its real cause said. A drop that succeeds settles any older debt for the same namespace. A namespace another live worktree reaches under the same slug (`feat.x` beside `feat/x`) is never dropped: it is that worktree's too.
`git worktree remove` drops its own entry even when it could not delete every file (root-owned files a container wrote). That removal is completed rather than left half-done — branch deleted, state purged, data dropped — and the leftover directory is named with the `sudo rm -rf` that deletes it. A removal git refused outright (a locked worktree) removed nothing, and keeps the data.
The default is to detach, since `clean` is the destructive command and removing a worktree without its data would leave an orphan behind on every iteration; `--keep-data` withholds it, under `--yes` as much as anywhere.
A service already down leaves a debt rather than being relit behind the reader's back for a `DROP DATABASE`. The debt lives in `/wtm/pending-removals.toml`, beside the repository, because the worktree's own state directory is exactly what `clean` removes. It is a **queue and not a registry** — entries are only ever added by a failure and removed by a success.
The debt is paid wherever the service is next seen up, by one piece of code, `flow/run/owed`:
* **`run up` and `run start`** settle it after their sequence, from any worktree. Cleaning late, with nothing running, is the common case, and waiting for someone to remember `prune` while a stack happened to be up is how debts piled up.
* **`clean` and `prune` ask, in their form**, before the recap — a *Data* step (`owed.DataStep`) shown only when a service holding the removed worktrees' data is down: start it and drop the data now, or keep it until it next starts. One question for every service down, never one per service, and never after the confirmation: the recap is the last action point, and it says what happens to each namespace — dropped, dropped after starting its service, or kept. Starting brings the service up from the main checkout (`owed.BringUp`), drops the namespaces, and releases main's hold — the service stops again unless a worktree took a claim meanwhile. `--yes` keeps deferring: starting a service nobody asked for is not a safe default. `--drop-data` answers the step ahead (a preset, so the recap still says which services it starts), which is how an unattended run — an agent's — asks for the drop; it excludes `--keep-data`. The step exists because a debt is only paid by a service **wtm** starts: someone who runs their database some other way would otherwise keep every deferred database forever.
* **`prune`** also settles older debts before its own removal, and says what is still owed (`2 namespaces still owed to postgres — dropped on its next start`) instead of passing over it.
Both commands go through `owed.Read` (what the worktrees hold, read while they still exist) and `owed.Dropper` (bring up what is down, drop one worktree's namespaces under a stage once it is gone, report, queue the rest), inside `flow/teardown`, so they cannot drift apart. Their `--output json` carries each namespace as `dropped`, `deferred` or `kept`.
A debt whose worktree **exists again** is withdrawn, never paid: the namespace is derived from the worktree's name, so it now belongs to the re-created worktree, and paying the debt would drop that worktree's data.
## `run init` and the compose granularity
[Section titled “run init and the compose granularity”](#run-init-and-the-compose-granularity)
wtm generates **one job per compose file**, not per service. A scope had therefore nothing to sit on: a single `docker-compose.yml` with eight services was one job.
So the scanner reports what each file declares (`domain.ComposeScan.Services`, with `Image` and `HasBuild`), the step enumerates **services**, and marking one shared **lifts it into a job of its own** (`docker compose -f up -d `, stopped with `stop ` and never `down`, which would tear the whole file apart). The file's own job then names the services that stayed, since `docker compose up` would otherwise start the lifted one a second time. A file with nothing left keeps no job.
The step sits **before** the ports step: a shared job takes no offset, so which services are shared must be settled before their ports are.
A service with a `build:` is shown with its reason and no answer to give. That is structural, not a guess about the image's name — such a service compiles this worktree's source, so sharing it would serve one worktree's build to all of them.
Where `run.toml` has an opinion it outranks detection, and a run that never put the question leaves what it declares standing (`ScopesAsked`, the same `(value, asked)` pair as `URLsAsked` and the others).
## What a shared service changes on the surfaces
[Section titled “What a shared service changes on the surfaces”](#what-a-shared-service-changes-on-the-surfaces)
* Its published host carries **no worktree segment** (`db.projet.localhost`). One instance cannot answer under two names, and keeping the segment would have two worktrees' `.env` files disagree about where a single service answers.
* Its compose volume and network names need no special handling: they are already templated `${COMPOSE_PROJECT_NAME:-default}`, and a service running in the main checkout inherits that checkout's project name. That name must therefore not carry the main's branch, or every checkout on the main starts a second shared stack and orphans the first with every worktree's namespace in it: `BranchEnv` names ordinal 0 by `rules.MainComposeProjectName` — the `COMPOSE_PROJECT_NAME` of the main's own `.env`, else the repository's slug, which is what `docker compose` picks there itself — and never reads the client's environment for it, since that belongs to whichever worktree the command ran from. `ResolveEnvPorts` writes the same name, so a compose run by hand in the main lands on the stack wtm started.
* A claim reports `pid: 0` and its own mark. Printing a PID beside three worktrees would read as three processes.
* A claim is **attachable**: it owns no stream, and the daemon resolves it to the one there is — so `run logs` works from any worktree. Its persisted tail is read from the main checkout's log directory, not from its own.
* Both the real job and every claim carry the main checkout they belong to. The daemon is machine-wide, so matching a claim to its service by name alone let two repositories that both declare `db` release each other's.
## What it costs when nothing is shared
[Section titled “What it costs when nothing is shared”](#what-it-costs-when-nothing-is-shared)
Nothing. `sharedContext` is resolved once per seam and only when `run.toml` declares a shared job — otherwise every `run` command, `run ps` included, would pay a `git worktree list` plus a full environment resolution for the main checkout.
# One branch, one worktree, one isolated dev stack.
> A worktree manager for teams that work on several branches at once, and let their agents do too.
## A worktree ready to code, in one command
[Section titled “A worktree ready to code, in one command”](#a-worktree-ready-to-code-in-one-command)
`git worktree` gives each branch a directory. Everything else is still on you: copy the `.env`, install dependencies, remember which directory holds which branch, and run two branches' dev servers without them fighting over the same ports, containers and databases.
Provisioned
`wtm create` copies the `.env` files, runs your `on_create` hooks (`pnpm install`, …) and shifts the ports. `wtm go` jumps into it.
Cleaned when merged
`wtm prune` removes every worktree whose PR is merged, in one pass, and refuses one that still holds uncommitted or unpushed work.
Stacked branches
Every worktree records its parent. `wtm tree` draws the forest, `wtm sync` rebases a branch and its descendants in order.
Many at once
`wtm create a b c`, `wtm exec --all -- pnpm test`: one command across worktrees, in parallel, each with its own environment.
## Your whole stack, once per worktree
[Section titled “Your whole stack, once per worktree”](#your-whole-stack-once-per-worktree)
Configure it once, share it with `wtm run export`, and every developer and every agent gets the full stack in its own worktree: its own ports, its own `COMPOSE_PROJECT_NAME`, its own database in a shared postgres, its own URL. Two branches run side by side; an agent can start the app it is working on and test it end to end without touching yours.

[How wtm run works](/guide/how-run-works/)Jobs, ports and the environment each worktree gets.
[Shared services](/guide/shared-services/)One postgres for the repository, one database per worktree.
## Built for agents
[Section titled “Built for agents”](#built-for-agents)
wtm is meant to be driven by something other than a person. `wtm agents install` adds a skill that teaches Claude Code or Cursor the commands and their unattended forms. Data commands take `--output json`, every change takes `--yes`, exit codes are stable, and `wtm events` streams every change as JSON Lines.
```bash
wtm create feat/a feat/b --yes --output json
wtm run up feat/a --yes -d
wtm events --output json | jq -c 'select(.type == "worktree.provisioned")'
```
This site is also served as plain text for agents: [`llms.txt`](/llms.txt), [`llms-full.txt`](/llms-full.txt).
## One screen for all of it
[Section titled “One screen for all of it”](#one-screen-for-all-of-it)
`wtm ui` shows every worktree, the branch tree, PR status and the running services, and updates live as worktrees come and go from any shell or agent.

## Install
[Section titled “Install”](#install)
* Homebrew
```bash
brew install LucasPcq/tap/wtm
```
* Go
```bash
go install github.com/LucasPcq/wtm@latest
```
* Binary
Download the archive for your platform from the [releases](https://github.com/LucasPcq/wtm/releases), then:
```bash
tar -xzf wtm_*_linux_amd64.tar.gz # or _linux_arm64, _darwin_amd64, _darwin_arm64
sudo mv wtm /usr/local/bin/
```
Then let `wtm go` change your directory, and set up a repository:
```bash
echo 'eval "$(wtm shell-init)"' >> ~/.zshrc # ~/.bashrc for bash
cd your-repo && wtm init
```
macOS and Linux, amd64 and arm64; Windows through WSL2. A specific version, updating and the other details are in [Installation](/guide/installation/).
[Getting started](/guide/getting-started/)Ten minutes from install to two branches running side by side.
[Recipes](/guide/recipes/)A pnpm monorepo, a compose app, a shared postgres, agents in parallel.
[Command reference](/reference/wtm/)Every command and flag, generated from --help.
[Changelog](/changelog/)What changed in each release.
# wtm
Orchestrate git worktrees and team dev workflows from the terminal
```plaintext
wtm [flags]
```
### Examples
[Section titled “Examples”](#examples)
```plaintext
# Once per repository
wtm init
# A worktree per branch, then jump into it
wtm create feat/login
wtm go feat/login
# Every worktree, its PR and its services, on one screen
wtm ui
```
### Options
[Section titled “Options”](#options)
```plaintext
-h, --help help for wtm
-q, --quiet Silence human output; errors and the exit code are unaffected, and --output json still emits its document
```
### SEE ALSO
[Section titled “SEE ALSO”](#see-also)
* [wtm agents](/reference/wtm-agents/) - Manage LLM agent integrations for wtm
* [wtm checkout](/reference/wtm-checkout/) - Create a worktree from an existing pull request
* [wtm clean](/reference/wtm-clean/) - Remove worktrees and their local branches
* [wtm config](/reference/wtm-config/) - Inspect or edit the project wtm config
* [wtm create](/reference/wtm-create/) - Create one or more worktrees
* [wtm env](/reference/wtm-env/) - Reconcile a worktree's .env against its template and value sources
* [wtm events](/reference/wtm-events/) - Stream worktree changes as they happen, in one repository or all of them
* [wtm exec](/reference/wtm-exec/) - Run one command in several worktrees, in parallel
* [wtm extract](/reference/wtm-extract/) - Move uncommitted changes to another worktree
* [wtm fast-forward](/reference/wtm-fast-forward/) - Advance worktree branches to their origin counterpart
* [wtm go](/reference/wtm-go/) - Switch to a worktree
* [wtm init](/reference/wtm-init/) - Initialize wtm configuration
* [wtm list](/reference/wtm-list/) - List all worktrees
* [wtm prune](/reference/wtm-prune/) - Remove finished worktrees (merged, closed PR or gone) in one pass
* [wtm relocate](/reference/wtm-relocate/) - Move worktrees to align with base_path and adopt external ones
* [wtm reparent](/reference/wtm-reparent/) - Change the parent one or more worktrees are rebased onto
* [wtm resolve](/reference/wtm-resolve/) - Resolve a branch to its worktree path
* [wtm run](/reference/wtm-run/) - Manage dev jobs (services + tasks)
* [wtm schema](/reference/wtm-schema/) - Inspect or extract bundled JSON Schemas
* [wtm shell-init](/reference/wtm-shell-init/) - Generate shell integration function
* [wtm sync](/reference/wtm-sync/) - Rebase selected worktrees onto their parent, in cascade
* [wtm tree](/reference/wtm-tree/) - Show the worktree forest (parent → child)
* [wtm ui](/reference/wtm-ui/) - Open the worktree dashboard
* [wtm upgrade](/reference/wtm-upgrade/) - Update wtm to the latest release
* [wtm version](/reference/wtm-version/) - Print wtm's version and the versions of its machine contracts
# wtm agents
Manage LLM agent integrations for wtm
```plaintext
wtm agents [flags]
```
### Options
[Section titled “Options”](#options)
```plaintext
-h, --help help for agents
```
### Options inherited from parent commands
[Section titled “Options inherited from parent commands”](#options-inherited-from-parent-commands)
```plaintext
-q, --quiet Silence human output; errors and the exit code are unaffected, and --output json still emits its document
```
### SEE ALSO
[Section titled “SEE ALSO”](#see-also)
* [wtm](/reference/wtm/) - Orchestrate git worktrees and team dev workflows from the terminal
* [wtm agents install](/reference/wtm-agents-install/) - Install the using-wtm skill into .claude / .cursor
# wtm agents install
Install the using-wtm skill into .claude / .cursor
### Synopsis
[Section titled “Synopsis”](#synopsis)
Detects which skill destinations exist (project and home-level .claude and .cursor) and installs the using-wtm skill into the ones you pick.
```plaintext
wtm agents install [flags]
```
### Examples
[Section titled “Examples”](#examples)
```plaintext
wtm agents install
# Every detected destination, no questions
wtm agents install --yes
# Also create the ones that don't exist yet, and report as JSON
wtm agents install --all --yes --output json
```
### Options
[Section titled “Options”](#options)
```plaintext
--all Include destinations that don't yet exist (creates skill dirs)
-h, --help help for install
--output string Output format: text or json (default "text")
--yes Non-interactive: install into every detected destination
```
### Options inherited from parent commands
[Section titled “Options inherited from parent commands”](#options-inherited-from-parent-commands)
```plaintext
-q, --quiet Silence human output; errors and the exit code are unaffected, and --output json still emits its document
```
### SEE ALSO
[Section titled “SEE ALSO”](#see-also)
* [wtm agents](/reference/wtm-agents/) - Manage LLM agent integrations for wtm
# wtm checkout
Create a worktree from an existing pull request
### Synopsis
[Section titled “Synopsis”](#synopsis)
Create a worktree from a pull request. A local branch of the PR's name is checked out as-is, keeping commits you never pushed; it is fast-forwarded when it is behind origin if you accept, or with --ff under --yes. Without arguments, shows an interactive picker of open PRs.
```plaintext
wtm checkout [number] [flags]
```
### Examples
[Section titled “Examples”](#examples)
```plaintext
# Pick among the open pull requests
wtm checkout
# Only the ones waiting for your review
wtm checkout --review
wtm checkout 42
# No prompts, with a JSON result
wtm checkout 42 --yes --output json
```
### Options
[Section titled “Options”](#options)
```plaintext
--env-from string Override env strategy (example, main, parent)
--ff Fast-forward the PR's branch to origin when it already exists locally and is behind (non-interactive; skipped when it has diverged)
--from string Parent branch for sync (defaults to the PR base branch)
-h, --help help for checkout
--isolation string How the new worktree stands against its source: isolated (its own ports, compose project and namespaces in shared services, in the .env and at run time) or verbatim (.env kept exactly as copied, run on its source's ports and data); defaults to run.toml's isolation, else isolated
--mine Show only your PRs
--output string Output format: text or json (default "text")
--review Show only PRs where you are requested as reviewer
-y, --yes Skip all prompts; resolve every decision from flags and safe defaults (PR number required)
```
### Options inherited from parent commands
[Section titled “Options inherited from parent commands”](#options-inherited-from-parent-commands)
```plaintext
-q, --quiet Silence human output; errors and the exit code are unaffected, and --output json still emits its document
```
### SEE ALSO
[Section titled “SEE ALSO”](#see-also)
* [wtm](/reference/wtm/) - Orchestrate git worktrees and team dev workflows from the terminal
# wtm clean
Remove worktrees and their local branches
### Synopsis
[Section titled “Synopsis”](#synopsis)
Remove git worktrees and delete their local branches. The remote branch is never touched. Without arguments, shows an interactive picker where several can be checked.
Several worktrees are removed one after the other: a failure does not stop the others, and the run exits with the first failure's code. Under --yes, one unsafe worktree (locked, dirty, unpushed, open PR) refuses the whole run before anything is removed, unless --force.
The removal runs in a fixed order: the worktree's jobs are stopped and checked gone (a job that will not stop refuses the removal unless --force), the on_clean hooks run, git removes the worktree — and only then is its data dropped. A failure before that last step leaves the data where it was.
By default, clean DROPS the namespaces the worktree carved out of shared services (a database per worktree in a shared postgres, say): the confirmation names each one, and --output json reports each as dropped, deferred or kept. --keep-data withholds the drop. A service that is down cannot take its data back: the form asks whether to start it now or keep the data until wtm next starts it; --yes keeps it, --drop-data starts it. A namespace another worktree reaches under the same name is never dropped.
```plaintext
wtm clean [branch...] [flags]
```
### Examples
[Section titled “Examples”](#examples)
```plaintext
# Pick the worktrees to remove
wtm clean
wtm clean feat/login
# Several at once, no prompts; their children move onto the nearest survivor
wtm clean feat/login feat/signup --yes --reparent-children
# Keep the databases it holds in shared services
wtm clean feat/login --yes --keep-data --output json
```
### Options
[Section titled “Options”](#options)
```plaintext
--drop-data Drop the removed worktrees' data now, starting the shared services that are down to do it
--force Lift safety refusals (locked/dirty/unpushed/open-PR); still asks to confirm unless --yes
-h, --help help for clean
--keep-data Keep the namespaces the removed worktrees carved out of shared services
--output string Output format: text or json (default "text")
--reparent-children Reparent orphaned child worktrees onto their nearest surviving ancestor (no prompt)
-y, --yes Skip all prompts; resolve every decision from flags and safe defaults (keeps safety checks unless --force)
```
### Options inherited from parent commands
[Section titled “Options inherited from parent commands”](#options-inherited-from-parent-commands)
```plaintext
-q, --quiet Silence human output; errors and the exit code are unaffected, and --output json still emits its document
```
### SEE ALSO
[Section titled “SEE ALSO”](#see-also)
* [wtm](/reference/wtm/) - Orchestrate git worktrees and team dev workflows from the terminal
# wtm config
Inspect or edit the project wtm config
### Synopsis
[Section titled “Synopsis”](#synopsis)
View the resolved config or open the project config.toml in $EDITOR. The file lives under /wtm/config.toml and is never committed.
```plaintext
wtm config [flags]
```
### Examples
[Section titled “Examples”](#examples)
```plaintext
wtm config show
wtm config edit
```
### Options
[Section titled “Options”](#options)
```plaintext
-h, --help help for config
```
### Options inherited from parent commands
[Section titled “Options inherited from parent commands”](#options-inherited-from-parent-commands)
```plaintext
-q, --quiet Silence human output; errors and the exit code are unaffected, and --output json still emits its document
```
### SEE ALSO
[Section titled “SEE ALSO”](#see-also)
* [wtm](/reference/wtm/) - Orchestrate git worktrees and team dev workflows from the terminal
* [wtm config edit](/reference/wtm-config-edit/) - Open the project config.toml in $EDITOR
* [wtm config show](/reference/wtm-config-show/) - Print the project config.toml
# wtm config edit
Open the project config.toml in $EDITOR
### Synopsis
[Section titled “Synopsis”](#synopsis)
Launch the editor on /wtm/config.toml. After save, the file is re-validated and any error is reported.
```plaintext
wtm config edit [flags]
```
### Examples
[Section titled “Examples”](#examples)
```plaintext
wtm config edit
EDITOR=vim wtm config edit
```
### Options
[Section titled “Options”](#options)
```plaintext
-h, --help help for edit
```
### Options inherited from parent commands
[Section titled “Options inherited from parent commands”](#options-inherited-from-parent-commands)
```plaintext
-q, --quiet Silence human output; errors and the exit code are unaffected, and --output json still emits its document
```
### SEE ALSO
[Section titled “SEE ALSO”](#see-also)
* [wtm config](/reference/wtm-config/) - Inspect or edit the project wtm config
# wtm config show
Print the project config.toml
```plaintext
wtm config show [flags]
```
### Examples
[Section titled “Examples”](#examples)
```plaintext
wtm config show
# Check the file, print nothing else
wtm config show --validate
wtm config show --output json
```
### Options
[Section titled “Options”](#options)
```plaintext
-h, --help help for show
--output string Output format: text or json (default "text")
--validate Validate the config instead of printing it
```
### Options inherited from parent commands
[Section titled “Options inherited from parent commands”](#options-inherited-from-parent-commands)
```plaintext
-q, --quiet Silence human output; errors and the exit code are unaffected, and --output json still emits its document
```
### SEE ALSO
[Section titled “SEE ALSO”](#see-also)
* [wtm config](/reference/wtm-config/) - Inspect or edit the project wtm config
# wtm create
Create one or more worktrees
### Synopsis
[Section titled “Synopsis”](#synopsis)
Create one or more git worktrees with env provisioning, metadata, and hooks. Several branches are created one after the other from the same source; a failure does not stop the others, and the run ends with what was created and what failed. A branch that already exists locally is checked out as-is, keeping its commits. Its parent can't be inferred, so --from then names the branch recorded for `wtm sync` — asked in the wizard, required without it. When run.toml declares jobs, a branch whose derived name a live worktree or another branch of the run already carries (feat.x next to feat/x) is refused. Without arguments, the wizard asks for the branches: tab adds another, enter continues.
```plaintext
wtm create [branch...] [flags]
```
### Examples
[Section titled “Examples”](#examples)
```plaintext
# Answer the wizard: branches, source, env strategy, isolation
wtm create
# Three worktrees from the base branch, no prompts
wtm create feat/login feat/billing fix/header --yes
# A stacked branch on top of feat/login
wtm create feat/login-ui --from feat/login --yes
# For a script or an agent: idempotent, with a JSON envelope
wtm create feat/login --if-not-exists --yes --output json
```
### Options
[Section titled “Options”](#options)
```plaintext
--env-from string Override env strategy (example, main, parent)
--ff Fast-forward to origin before creating — the source branch, or the branch itself when it already exists locally (non-interactive; skipped when it has diverged)
--from string Source branch to start from — or, when the branch already exists locally, the parent to record for wtm sync (required there without the wizard)
-h, --help help for create
--if-not-exists Succeed silently if the worktree already exists (idempotent)
--isolation string How the new worktree stands against its source: isolated (its own ports, compose project and namespaces in shared services, in the .env and at run time) or verbatim (.env kept exactly as copied, run on its source's ports and data); defaults to run.toml's isolation, else isolated
--output string Output format: text or json (default "text")
-y, --yes Skip all prompts; resolve every decision from flags and safe defaults (branch names required; source defaults to the base branch for a new branch, and --from is required for one that already exists)
```
### Options inherited from parent commands
[Section titled “Options inherited from parent commands”](#options-inherited-from-parent-commands)
```plaintext
-q, --quiet Silence human output; errors and the exit code are unaffected, and --output json still emits its document
```
### SEE ALSO
[Section titled “SEE ALSO”](#see-also)
* [wtm](/reference/wtm/) - Orchestrate git worktrees and team dev workflows from the terminal
# wtm env
Reconcile a worktree's .env against its template and value sources
### Synopsis
[Section titled “Synopsis”](#synopsis)
Detect and fix .env drift in a worktree: add the keys its sources have and it lacks, and with --mode refresh settle the values that diverge from the source. Values come from the strategy the worktree was created with (example, main or parent); --from overrides it for one run. When run.toml declares ports, the values wtm owns are then settled on the worktree's isolation.
Pass a worktree, or omit it to pick one. --check reports and writes nothing. A report prints only the values wtm writes (ports, owned values); the others, secrets included, are withheld unless --show-values. Unattended (--yes, no terminal, --output json) it applies safe additions only: conflicts need --on-conflict, orphans --prune.
A run keeps how the worktree runs unless asked: the wizard offers to switch a worktree between isolated and verbatim (--isolation), and the main checkout between port and named addresses (--addressing) — see the isolation and addressing guides.
```plaintext
wtm env [worktree] [flags]
```
### Examples
[Section titled “Examples”](#examples)
```plaintext
# Pick a worktree and reconcile its .env files
wtm env
# Read-only drift report, for a script
wtm env feat/login --check --output json
# Also settle the values that diverge, and drop the keys no source has
wtm env feat/login --mode refresh --on-conflict overwrite --prune --yes
# Give a worktree created before 0.28 its own ports and compose project
wtm env feat/login --isolation isolated --yes
# Reconcile the main checkout, moving its addresses back to ports
wtm env main --addressing ports --yes
```
### Options
[Section titled “Options”](#options)
```plaintext
--addressing string Write the main checkout's linked addresses as ports (as without wtm) or names (served by the run proxy); default: what its .env spells
--check Read-only drift report; write nothing
--from string Override the value source strategy (example, main, parent)
-h, --help help for env
--isolation string Switch the worktree to isolated (its own ports, compose project and namespaces) or verbatim (the values wtm owns back to the source's)
--mode string Reconciliation mode: add (fill gaps) or refresh (also settle value conflicts) (default "add")
--on-conflict string Conflict resolution with --mode refresh: keep (default) or overwrite
--output string Output format: text or json (default "text")
--prune Remove orphan keys (present in the .env but in no source)
--show-values Print the values of keys wtm does not write (secrets included); withheld by default
-y, --yes Skip all prompts; resolve every decision from flags and safe defaults (additions only)
```
### Options inherited from parent commands
[Section titled “Options inherited from parent commands”](#options-inherited-from-parent-commands)
```plaintext
-q, --quiet Silence human output; errors and the exit code are unaffected, and --output json still emits its document
```
### SEE ALSO
[Section titled “SEE ALSO”](#see-also)
* [wtm](/reference/wtm/) - Orchestrate git worktrees and team dev workflows from the terminal
# wtm events
Stream worktree changes as they happen, in one repository or all of them
### Synopsis
[Section titled “Synopsis”](#synopsis)
Print the repository's worktrees, then every change made to them, whoever made it: a command in another shell, an agent, or `wtm ui`. The stream opens on a snapshot of every worktree and a ready line, then carries one event per change — created, provisioned, updated, relocated, reparented, deprovisioned, removed. With --output json each line is one JSON object (JSON Lines), the contract an integration reads; its schema ships with wtm. If the run daemon stops, the stream waits for it and opens again on a fresh snapshot: treat every event as an upsert keyed by branch, and every snapshot as a reset. It runs until interrupted or until the reader of its pipe goes away. With --all it follows every repository wtm was used in, whatever the current directory or $GIT_DIR, as it does when run outside any repository without --repo: a snapshot for each, one ready line, then repo.added and repo.removed as they come and go, a new repository's snapshot right after its repo.added. It ends on a code no retry can change: 2 for --all with --repo, 12 in a repository wtm was never initialized in, 21 when --repo is not in a git repository, and 20 if it receives an event of a schema newer than its own.
```plaintext
wtm events [flags]
```
### Examples
[Section titled “Examples”](#examples)
```plaintext
# Watch this repository's worktrees
wtm events
# The JSON Lines contract, filtered with jq
wtm events --output json | jq -c 'select(.type == "worktree.created")'
# Another repository than the current one
wtm events --repo ~/code/app --output json
# Every repository wtm knows, wherever it runs
wtm events --all --output json
```
### Options
[Section titled “Options”](#options)
```plaintext
--all Watch every repository wtm knows, whatever the current directory or $GIT_DIR
-h, --help help for events
--output string Output format: text or json (default "text")
--repo string Watch the repository holding this path instead of the current one
```
### Options inherited from parent commands
[Section titled “Options inherited from parent commands”](#options-inherited-from-parent-commands)
```plaintext
-q, --quiet Silence human output; errors and the exit code are unaffected, and --output json still emits its document
```
### SEE ALSO
[Section titled “SEE ALSO”](#see-also)
* [wtm](/reference/wtm/) - Orchestrate git worktrees and team dev workflows from the terminal
# wtm exec
Run one command in several worktrees, in parallel
### Synopsis
[Section titled “Synopsis”](#synopsis)
Run a shell line in each selected worktree, in parallel, and report which passed. Everything after -- is run with /bin/sh -c from the worktree's root, with that worktree's environment: the variables describing the worktree you stand in are removed, and the target's run variables (compose project, shifted ports) are added when it has them — the same ones its hooks get. stdin is closed. Each worktree's whole output is kept in a log under the state directory; failures show its tail. Pass worktree names (branches), --all, or nothing to pick interactively; without -- the wizard asks for the command too, and a run that cannot ask refuses. The run exits 1 when any command failed; each worktree's own exit code is in the report.
```plaintext
wtm exec [worktree...] [-- ] [flags]
```
### Examples
[Section titled “Examples”](#examples)
```plaintext
# Pick the worktrees and type the command in the wizard
wtm exec
# Run the tests on two branches
wtm exec feat/login feat/signup -- pnpm test
# Reinstall everywhere after a lockfile bump, two at a time
wtm exec --all --jobs 2 -- pnpm install
# Read each worktree's last commit
wtm exec --all --yes --print -- git log -1 --oneline
# For an agent
wtm exec --all --yes --output json -- pnpm typecheck
```
### Options
[Section titled “Options”](#options)
```plaintext
--all Run in every worktree, the main checkout included
-h, --help help for exec
--jobs int How many commands run at once (0: one per CPU)
--output string Output format: text or json (default "text")
--print Also show the full output of every worktree, successes included
-y, --yes Skip all prompts (requires worktree names or --all, and the command after --)
```
### Options inherited from parent commands
[Section titled “Options inherited from parent commands”](#options-inherited-from-parent-commands)
```plaintext
-q, --quiet Silence human output; errors and the exit code are unaffected, and --output json still emits its document
```
### SEE ALSO
[Section titled “SEE ALSO”](#see-also)
* [wtm](/reference/wtm/) - Orchestrate git worktrees and team dev workflows from the terminal
# wtm extract
Move uncommitted changes to another worktree
### Synopsis
[Section titled “Synopsis”](#synopsis)
Move a subset of a worktree's uncommitted changes to another worktree (new or existing) to split an oversized PR or isolate unrelated work.
The source worktree is the first thing chosen: pass its branch as \[source], or omit it to pick interactively from the worktrees that have changes. A source is required when there is no terminal or with --output json.
A --to branch that already exists locally is checked out as-is, keeping its commits. Its parent can't be inferred, so --from then names the branch recorded for `wtm sync` — asked in the wizard, required without it.
Untracked files are listed one by one, including inside brand-new directories, so you can take part of a new folder; gitignored files are never listed.
On conflict it aborts by default, leaving the source intact; --on-conflict resolve applies conflict markers in the target so you can resolve them like a rebase. A file that merely already exists in the target counts as a conflict too.
```plaintext
wtm extract [source] [flags]
```
### Examples
[Section titled “Examples”](#examples)
```plaintext
# Pick the source, the files and the target
wtm extract
# Move a directory's changes to a new branch
wtm extract feat/login --files apps/api --to feat/login-api --yes
# Copy one file instead, onto a branch stacked on the source
wtm extract feat/login --files apps/web/login.ts --to feat/login-web --from feat/login --keep --yes
```
### Options
[Section titled “Options”](#options)
```plaintext
--ff Fast-forward the parent branch to origin before creating the target (non-interactive; skipped when it has diverged)
--files strings Files to extract, or a directory to take everything below it (skips interactive selection)
--from string Parent branch when creating the target worktree
-h, --help help for extract
--isolation string How the new worktree stands against its source: isolated (its own ports, compose project and namespaces in shared services, in the .env and at run time) or verbatim (.env kept exactly as copied, run on its source's ports and data); defaults to run.toml's isolation, else isolated
--keep Copy instead of move (keep the changes in the source)
--on-conflict string On conflict: abort (default) or resolve (write conflict markers in the target)
--output string Output format: text or json (default "text")
--to string Target worktree branch; created if it does not exist
-y, --yes Skip all prompts; resolve every decision from flags and safe defaults (requires a source arg, --files and --to; --from is also required when --to already exists locally; errors if a selection is missing)
```
### Options inherited from parent commands
[Section titled “Options inherited from parent commands”](#options-inherited-from-parent-commands)
```plaintext
-q, --quiet Silence human output; errors and the exit code are unaffected, and --output json still emits its document
```
### SEE ALSO
[Section titled “SEE ALSO”](#see-also)
* [wtm](/reference/wtm/) - Orchestrate git worktrees and team dev workflows from the terminal
# wtm fast-forward
Advance worktree branches to their origin counterpart
### Synopsis
[Section titled “Synopsis”](#synopsis)
Fast-forward one or more managed worktrees to origin/, and nothing else: no rebase onto the parent, no merge. Pass branch names, --all for every worktree, or no arguments to pick interactively. A branch that has diverged from origin is refused — `wtm sync` is the command that replays local commits onto it, and --force does not lift that refusal. A worktree with uncommitted changes is refused too; --force fast-forwards it anyway, and git still refuses if a modified file would be overwritten.
```plaintext
wtm fast-forward [branch...] [flags]
```
### Examples
[Section titled “Examples”](#examples)
```plaintext
# Pick the worktrees to bring up to origin
wtm fast-forward
wtm ff feat/login
# Every worktree, no prompts
wtm fast-forward --all --yes --output json
```
### Options
[Section titled “Options”](#options)
```plaintext
--all Fast-forward every managed worktree
--force Fast-forward a worktree that has uncommitted changes
-h, --help help for fast-forward
--output string Output format: text or json (default "text")
-y, --yes Skip all prompts; resolve every decision from flags and safe defaults (requires branch args or --all)
```
### Options inherited from parent commands
[Section titled “Options inherited from parent commands”](#options-inherited-from-parent-commands)
```plaintext
-q, --quiet Silence human output; errors and the exit code are unaffected, and --output json still emits its document
```
### SEE ALSO
[Section titled “SEE ALSO”](#see-also)
* [wtm](/reference/wtm/) - Orchestrate git worktrees and team dev workflows from the terminal
# wtm go
Switch to a worktree
### Synopsis
[Section titled “Synopsis”](#synopsis)
Navigate to a worktree directory. Requires shell integration to work.
```plaintext
wtm go [branch] [flags]
```
### Examples
[Section titled “Examples”](#examples)
```plaintext
# Pick a worktree
wtm go
wtm go feat/login
# Back to the main checkout
wtm go main
```
### Options
[Section titled “Options”](#options)
```plaintext
-h, --help help for go
```
### Options inherited from parent commands
[Section titled “Options inherited from parent commands”](#options-inherited-from-parent-commands)
```plaintext
-q, --quiet Silence human output; errors and the exit code are unaffected, and --output json still emits its document
```
### SEE ALSO
[Section titled “SEE ALSO”](#see-also)
* [wtm](/reference/wtm/) - Orchestrate git worktrees and team dev workflows from the terminal
# wtm init
Initialize wtm configuration
### Synopsis
[Section titled “Synopsis”](#synopsis)
Interactive wizard to set up global config and project config in /wtm/config.toml. Pass --yes (or any config flag) to bootstrap from flags + auto-detection instead; without a terminal, init does so on its own and never prompts. Use --only env|hooks|worktrees to re-run init for specific sections and regenerate them cleanly. Services & tasks are configured separately with `wtm run init`.
```plaintext
wtm init [flags]
```
### Examples
[Section titled “Examples”](#examples)
```plaintext
# The wizard
wtm init
# Unattended, from detection
wtm init --yes
wtm init --yes --base-path ../acme.trees --install-command "pnpm install"
# Regenerate the hooks section only
wtm init --only hooks
```
### Options
[Section titled “Options”](#options)
```plaintext
--base-branch string Default base branch for new worktrees
--base-path string Worktree directory, relative to repo root
--clean-command string Command to run before removing a worktree
--env-strategy string Env provisioning strategy: example, main, or parent
-h, --help help for init
--install-command string Command to run after creating a worktree
--only strings Re-init only these sections (env, hooks, worktrees); regenerates them cleanly
--shell string Global shell: zsh, bash, or fish
--skip-clean Skip on_clean hooks config
--skip-env Skip .env provisioning config
--skip-hooks Skip on_create hooks config
-y, --yes Run unattended: bootstrap (or re-init) from flags + auto-detection; never prompt
```
### Options inherited from parent commands
[Section titled “Options inherited from parent commands”](#options-inherited-from-parent-commands)
```plaintext
-q, --quiet Silence human output; errors and the exit code are unaffected, and --output json still emits its document
```
### SEE ALSO
[Section titled “SEE ALSO”](#see-also)
* [wtm](/reference/wtm/) - Orchestrate git worktrees and team dev workflows from the terminal
# wtm list
List all worktrees
### Synopsis
[Section titled “Synopsis”](#synopsis)
List all git worktrees with their status, PR info, and running services.
```plaintext
wtm list [flags]
```
### Examples
[Section titled “Examples”](#examples)
```plaintext
wtm list
# With each worktree's pull request, as JSON
wtm list --with-prs --output json
```
### Options
[Section titled “Options”](#options)
```plaintext
-h, --help help for list
--output string Output format: text or json (default "text")
--with-prs Include GitHub PR info in non-interactive output (fetched eagerly)
```
### Options inherited from parent commands
[Section titled “Options inherited from parent commands”](#options-inherited-from-parent-commands)
```plaintext
-q, --quiet Silence human output; errors and the exit code are unaffected, and --output json still emits its document
```
### SEE ALSO
[Section titled “SEE ALSO”](#see-also)
* [wtm](/reference/wtm/) - Orchestrate git worktrees and team dev workflows from the terminal
# wtm prune
Remove finished worktrees (merged, closed PR or gone) in one pass
### Synopsis
[Section titled “Synopsis”](#synopsis)
Batch-remove worktrees whose work is done, reparenting any surviving children onto their nearest surviving ancestor (like `clean --reparent-children`). Whether work is "done" is read from GitHub via the `gh` CLI — never guessed from local commits — so squash- and rebase-merges are detected correctly. By default prune considers every finished worktree: merged PR, closed PR, or upstream branch gone. The reason flags restrict to specific categories — --merged (PR merged), --closed (PR closed unmerged), --gone (remote branch deleted).
\--merged and --closed require the GitHub CLI (`gh`) to be installed and authenticated; without it they match nothing and prune prints a notice — only --gone still applies. gone-detection first fetches the worktrees' branches from origin, dropping the remote-tracking refs of those deleted there (pass --no-fetch to skip). Only branches with a worktree are read, on git and on GitHub: the remote-tracking refs of the other branches are left as they are.
On a TTY, matches are shown for review (unsafe ones unchecked), then a prune confirmation, then — like clean — a dedicated confirmation to reparent surviving children onto their nearest surviving ancestor (or leave them orphaned). The main checkout and base branch are always protected; the current worktree is removed and the shell redirected to the base repo. Like clean, worktrees that are locked, dirty, have unpushed commits, or have an open PR are unsafe and need --force. Use --yes to skip the prompts (required with --output json); non-interactively, children are left orphaned unless --reparent-children is passed. --dry-run previews without changing anything.
Like clean, prune gives back the data the removed worktrees carved out of shared services (--keep-data withholds it); when such a service is down, the form asks whether to start it and drop the data now, or keep it until the service next starts. --yes keeps it; --drop-data drops it, starting the services that are down.
Each worktree goes through clean's whole sequence — jobs stopped, hooks, removal, then its data — before the next one starts. The first that fails stops the prune: the ones before it are gone with their data, it and the ones after keep theirs, and the report (and the `failed` field of --output json) names where it stopped.
```plaintext
wtm prune [flags]
```
### Examples
[Section titled “Examples”](#examples)
```plaintext
# Review every finished worktree, then confirm
wtm prune
# Only show what would go
wtm prune --dry-run
# Every worktree whose PR was merged, no prompts
wtm prune --merged --yes --reparent-children
```
### Options
[Section titled “Options”](#options)
```plaintext
--closed Restrict to worktrees whose PR was closed without merging (needs gh)
--drop-data Drop the removed worktrees' data now, starting the shared services that are down to do it
--dry-run Preview what would be pruned without removing anything
--force Lift safety refusals (locked/dirty/unpushed/open-PR): also remove unsafe worktrees; still asks to confirm unless --yes
--gone Restrict to worktrees whose upstream branch was deleted on the remote
-h, --help help for prune
--keep-data Keep the namespaces the removed worktrees carved out of shared services
--merged Restrict to worktrees whose PR was merged on GitHub (needs gh)
--no-fetch Skip the fetch of the worktrees' branches that gone-detection performs; use already-fetched state
--output string Output format: text or json (default "text")
--reparent-children Reparent orphaned child worktrees onto their nearest surviving ancestor (no prompt)
-y, --yes Skip all prompts; keep every match without the selection picker (use --force for unsafe worktrees)
```
### Options inherited from parent commands
[Section titled “Options inherited from parent commands”](#options-inherited-from-parent-commands)
```plaintext
-q, --quiet Silence human output; errors and the exit code are unaffected, and --output json still emits its document
```
### SEE ALSO
[Section titled “SEE ALSO”](#see-also)
* [wtm](/reference/wtm/) - Orchestrate git worktrees and team dev workflows from the terminal
# wtm relocate
Move worktrees to align with base_path and adopt external ones
### Synopsis
[Section titled “Synopsis”](#synopsis)
Reconcile every worktree with the configured base_path. Worktrees not under it are moved (git worktree move) and worktrees created outside wtm are adopted (their parent recorded so `wtm sync` works). Pass --to to change base_path and move existing worktrees to the new location. Dirty or locked worktrees are skipped unless --force; an occupied target path is never overwritten, and a worktree whose jobs are running is never moved (stop them with `wtm run down ` first). Adoption keeps what the worktree's meta.json already records (isolation, namespaces, ordinal).
```plaintext
wtm relocate [flags]
```
### Examples
[Section titled “Examples”](#examples)
```plaintext
# Show the plan first
wtm relocate --dry-run
wtm relocate
# Move every worktree under a new directory
wtm relocate --to ../acme.trees --yes
```
### Options
[Section titled “Options”](#options)
```plaintext
--dry-run Preview the plan without moving or adopting anything
--force Lift safety refusals (dirty/locked): move those worktrees too; still asks to confirm unless --yes
-h, --help help for relocate
--output string Output format: text or json (default "text")
--to string New base_path (relative to repo root); also moves existing worktrees there
-y, --yes Skip all prompts; resolve every decision from flags and safe defaults (parents default to the base branch)
```
### Options inherited from parent commands
[Section titled “Options inherited from parent commands”](#options-inherited-from-parent-commands)
```plaintext
-q, --quiet Silence human output; errors and the exit code are unaffected, and --output json still emits its document
```
### SEE ALSO
[Section titled “SEE ALSO”](#see-also)
* [wtm](/reference/wtm/) - Orchestrate git worktrees and team dev workflows from the terminal
# wtm reparent
Change the parent one or more worktrees are rebased onto
### Synopsis
[Section titled “Synopsis”](#synopsis)
Change the recorded parent (source branch) of one or more worktrees. Only the metadata is updated — the rebase happens on the next `wtm sync`. Pass the worktrees and --to , or run with no arguments to multi-select interactively. The new parent must exist as a local or origin remote-tracking branch (origin/x), and the resulting parent chain must stay acyclic.
```plaintext
wtm reparent [branch...] [flags]
```
### Examples
[Section titled “Examples”](#examples)
```plaintext
# Pick the worktrees and their new parent
wtm reparent
# feat/login was merged: stack its child on main, then rebase it
wtm reparent feat/login-ui --to main --yes
wtm sync feat/login-ui
```
### Options
[Section titled “Options”](#options)
```plaintext
-h, --help help for reparent
--output string Output format: text or json (default "text")
--to string New parent branch to rebase onto
-y, --yes Skip all prompts; resolve every decision from flags and safe defaults (needs at least one worktree and --to)
```
### Options inherited from parent commands
[Section titled “Options inherited from parent commands”](#options-inherited-from-parent-commands)
```plaintext
-q, --quiet Silence human output; errors and the exit code are unaffected, and --output json still emits its document
```
### SEE ALSO
[Section titled “SEE ALSO”](#see-also)
* [wtm](/reference/wtm/) - Orchestrate git worktrees and team dev workflows from the terminal
# wtm resolve
Resolve a branch to its worktree path
```plaintext
wtm resolve [branch] [flags]
```
### Examples
[Section titled “Examples”](#examples)
```plaintext
wtm resolve feat/login
# Use it in a script
cd "$(wtm resolve feat/login)"
wtm resolve feat/login --output json
```
### Options
[Section titled “Options”](#options)
```plaintext
-h, --help help for resolve
--output string Output format: text or json (default "text")
```
### Options inherited from parent commands
[Section titled “Options inherited from parent commands”](#options-inherited-from-parent-commands)
```plaintext
-q, --quiet Silence human output; errors and the exit code are unaffected, and --output json still emits its document
```
### SEE ALSO
[Section titled “SEE ALSO”](#see-also)
* [wtm](/reference/wtm/) - Orchestrate git worktrees and team dev workflows from the terminal
# wtm run
Manage dev jobs (services + tasks)
### Synopsis
[Section titled “Synopsis”](#synopsis)
Run commands and profiles declared in /wtm/run.toml — long-running services and one-shot tasks.
Vocabulary: job the unit wtm runs; its kind is service (long-running) or task (one-shot) profile a named, ordered group of jobs compose stack a job that runs `docker compose`; an isolated worktree gets its own compose project shared service a job with scope = "shared": one instance for the repository, run in the main checkout; a worktree holding it reports it as joined namespace a worktree's own part of a shared service — a database, a realm named URL the address the run proxy serves () port URL the job's own port (), printed with --raw isolation isolated: the worktree gets its own ports, compose project and namespaces; verbatim: it keeps its source's values, and so shares its source's data touches the services whose data a task changes (a migration, a reset, a seed) foreign data data this worktree does not own: its source's when it is verbatim, every worktree's for a shared service with no namespace; a job whose touches reach it is refused unless --force \[worktree] a worktree's branch name, never a path; omitted, the current worktree
```plaintext
wtm run [flags]
```
### Examples
[Section titled “Examples”](#examples)
```plaintext
# Once per repository: detect compose files and package scripts
wtm run init
# Start the default profile in this worktree
wtm run up
# What runs, across every repository
wtm run ps
wtm run down
```
### Options
[Section titled “Options”](#options)
```plaintext
-h, --help help for run
```
### Options inherited from parent commands
[Section titled “Options inherited from parent commands”](#options-inherited-from-parent-commands)
```plaintext
-q, --quiet Silence human output; errors and the exit code are unaffected, and --output json still emits its document
```
### SEE ALSO
[Section titled “SEE ALSO”](#see-also)
* [wtm](/reference/wtm/) - Orchestrate git worktrees and team dev workflows from the terminal
* [wtm run addressing](/reference/wtm-run-addressing/) - Switch how the .env files spell a job's address
* [wtm run daemon](/reference/wtm-run-daemon/) - Inspect, stop or restart the process that runs the jobs
* [wtm run down](/reference/wtm-run-down/) - Stop a worktree's running jobs
* [wtm run export](/reference/wtm-run-export/) - Export run.toml as JSON on stdout
* [wtm run import](/reference/wtm-run-import/) - Replace run.toml with a JSON run config
* [wtm run init](/reference/wtm-run-init/) - Configure the run module (services & tasks) for this repo
* [wtm run job](/reference/wtm-run-job/) - Add, remove, or edit jobs in run.toml
* [wtm run list](/reference/wtm-run-list/) - List jobs and profiles declared in run.toml
* [wtm run logs](/reference/wtm-run-logs/) - Attach to a job's output
* [wtm run open](/reference/wtm-run-open/) - Open a job's URL in the browser
* [wtm run profile](/reference/wtm-run-profile/) - Add, remove, or edit profiles in run.toml
* [wtm run proxy](/reference/wtm-run-proxy/) - Inspect and install the redirection that serves named URLs on port 80
* [wtm run ps](/reference/wtm-run-ps/) - List currently running jobs
* [wtm run start](/reference/wtm-run-start/) - Start a single job
* [wtm run stop](/reference/wtm-run-stop/) - Stop one job, in one or more worktrees
* [wtm run up](/reference/wtm-run-up/) - Start a profile's jobs
* [wtm run url](/reference/wtm-run-url/) - Print where a job is reachable in a worktree
# wtm run addressing
Switch how the .env files spell a job's address
### Synopsis
[Section titled “Synopsis”](#synopsis)
Set run.toml's addressing — named URLs () or port URLs () — then settle the .env of the worktrees that spell the other one. Settling runs even when the mode is already the one given, for a worktree an earlier switch left out of step.
The main checkout is settled back to ports, never onto names: it is the checkout that works without wtm, and `wtm env main` is how it is moved onto names.
Without an argument, prompts for the mode; under --yes the argument is required and the worktrees are settled unless --keep-env is passed.
```plaintext
wtm run addressing [names|ports] [flags]
```
### Examples
[Section titled “Examples”](#examples)
```plaintext
# Pick the mode
wtm run addressing
wtm run addressing ports --yes
# Switch run.toml only, leaving the .env files as they are
wtm run addressing names --yes --keep-env
```
### Options
[Section titled “Options”](#options)
```plaintext
-h, --help help for addressing
--keep-env Switch run.toml only, leaving the worktrees' .env files as they are
--output string Output format: text or json (default "text")
-y, --yes Skip the prompts; [names|ports] is then required
```
### Options inherited from parent commands
[Section titled “Options inherited from parent commands”](#options-inherited-from-parent-commands)
```plaintext
-q, --quiet Silence human output; errors and the exit code are unaffected, and --output json still emits its document
```
### SEE ALSO
[Section titled “SEE ALSO”](#see-also)
* [wtm run](/reference/wtm-run/) - Manage dev jobs (services + tasks)
# wtm run daemon
Inspect, stop or restart the process that runs the jobs
### Synopsis
[Section titled “Synopsis”](#synopsis)
Jobs are started by a background daemon shared by every repository. It exits on its own once no foreground job is left; detached services keep running without it and are picked back up by the next one.
```plaintext
wtm run daemon [flags]
```
### Examples
[Section titled “Examples”](#examples)
```plaintext
wtm run daemon status
wtm run daemon restart
```
### Options
[Section titled “Options”](#options)
```plaintext
-h, --help help for daemon
```
### Options inherited from parent commands
[Section titled “Options inherited from parent commands”](#options-inherited-from-parent-commands)
```plaintext
-q, --quiet Silence human output; errors and the exit code are unaffected, and --output json still emits its document
```
### SEE ALSO
[Section titled “SEE ALSO”](#see-also)
* [wtm run](/reference/wtm-run/) - Manage dev jobs (services + tasks)
* [wtm run daemon restart](/reference/wtm-run-daemon-restart/) - Hand the jobs over to a daemon built from this binary
* [wtm run daemon status](/reference/wtm-run-daemon-status/) - Report whether a daemon is running, and which build it is
* [wtm run daemon stop](/reference/wtm-run-daemon-stop/) - Stop the daemon, leaving detached services running
# wtm run daemon restart
Hand the jobs over to a daemon built from this binary
### Synopsis
[Section titled “Synopsis”](#synopsis)
Stop the running daemon and start one from this binary. This is the way out of a version mismatch: the daemon is what runs the jobs, so an older one keeps serving its own behavior until it is replaced. Detached services keep running across the restart and are picked back up; foreground ones are stopped.
```plaintext
wtm run daemon restart [flags]
```
### Examples
[Section titled “Examples”](#examples)
```plaintext
# After an upgrade, when a run command reports the daemon's version
wtm run daemon restart
wtm run daemon restart --yes
```
### Options
[Section titled “Options”](#options)
```plaintext
-h, --help help for restart
--output string Output format: text or json (default "text")
-y, --yes Skip the confirmation
```
### Options inherited from parent commands
[Section titled “Options inherited from parent commands”](#options-inherited-from-parent-commands)
```plaintext
-q, --quiet Silence human output; errors and the exit code are unaffected, and --output json still emits its document
```
### SEE ALSO
[Section titled “SEE ALSO”](#see-also)
* [wtm run daemon](/reference/wtm-run-daemon/) - Inspect, stop or restart the process that runs the jobs
# wtm run daemon status
Report whether a daemon is running, and which build it is
```plaintext
wtm run daemon status [flags]
```
### Examples
[Section titled “Examples”](#examples)
```plaintext
wtm run daemon status
wtm run daemon status --output json
```
### Options
[Section titled “Options”](#options)
```plaintext
-h, --help help for status
--output string Output format: text or json (default "text")
```
### Options inherited from parent commands
[Section titled “Options inherited from parent commands”](#options-inherited-from-parent-commands)
```plaintext
-q, --quiet Silence human output; errors and the exit code are unaffected, and --output json still emits its document
```
### SEE ALSO
[Section titled “SEE ALSO”](#see-also)
* [wtm run daemon](/reference/wtm-run-daemon/) - Inspect, stop or restart the process that runs the jobs
# wtm run daemon stop
Stop the daemon, leaving detached services running
### Synopsis
[Section titled “Synopsis”](#synopsis)
Stop the background daemon. Foreground services die with it — they are drained through a terminal it owns. Detached services (those with a stop command) keep running, and the next daemon picks them back up.
```plaintext
wtm run daemon stop [flags]
```
### Examples
[Section titled “Examples”](#examples)
```plaintext
wtm run daemon stop
wtm run daemon stop --yes
```
### Options
[Section titled “Options”](#options)
```plaintext
-h, --help help for stop
--output string Output format: text or json (default "text")
-y, --yes Skip the confirmation
```
### Options inherited from parent commands
[Section titled “Options inherited from parent commands”](#options-inherited-from-parent-commands)
```plaintext
-q, --quiet Silence human output; errors and the exit code are unaffected, and --output json still emits its document
```
### SEE ALSO
[Section titled “SEE ALSO”](#see-also)
* [wtm run daemon](/reference/wtm-run-daemon/) - Inspect, stop or restart the process that runs the jobs
# wtm run down
Stop a worktree's running jobs
### Synopsis
[Section titled “Synopsis”](#synopsis)
Stop the jobs running in \[worktree] — the current one when omitted, picked interactively when there is a terminal. With --profile, stops only that profile's jobs. Jobs running in other worktrees are never touched, unless --all is given: it stops every worktree of this repository, without asking, and lists each one it emptied. Other repositories are never touched.
```plaintext
wtm run down [worktree...] [flags]
```
### Examples
[Section titled “Examples”](#examples)
```plaintext
wtm run down
wtm run down feat/login --profile backend
# Every worktree of this repository
wtm run down --all --yes
```
### Options
[Section titled “Options”](#options)
```plaintext
--all Stop the jobs of every worktree of this repository
-h, --help help for down
--output string Output format: text or json (default "text")
--profile string Stop only this profile's jobs (default: every job the worktree runs)
-y, --yes Skip all prompts; stops what the worktree has running
```
### Options inherited from parent commands
[Section titled “Options inherited from parent commands”](#options-inherited-from-parent-commands)
```plaintext
-q, --quiet Silence human output; errors and the exit code are unaffected, and --output json still emits its document
```
### SEE ALSO
[Section titled “SEE ALSO”](#see-also)
* [wtm run](/reference/wtm-run/) - Manage dev jobs (services + tasks)
# wtm run export
Export run.toml as JSON on stdout
### Synopsis
[Section titled “Synopsis”](#synopsis)
Emit the current run config as JSON on stdout, whatever --output says: like run url, this is machine output and is never framed. Pipe to a file and use with wtm run import to share configurations.
```plaintext
wtm run export [flags]
```
### Examples
[Section titled “Examples”](#examples)
```plaintext
wtm run export > run.json
# One profile and its jobs
wtm run export --profile backend > backend.json
# Copy the layout into another clone
wtm run export | (cd ../other-clone && wtm run import - --yes)
```
### Options
[Section titled “Options”](#options)
```plaintext
-h, --help help for export
--output string Output format: text or json (default "text")
--profile string Export only this profile and its jobs
```
### Options inherited from parent commands
[Section titled “Options inherited from parent commands”](#options-inherited-from-parent-commands)
```plaintext
-q, --quiet Silence human output; errors and the exit code are unaffected, and --output json still emits its document
```
### SEE ALSO
[Section titled “SEE ALSO”](#see-also)
* [wtm run](/reference/wtm-run/) - Manage dev jobs (services + tasks)
# wtm run import
Replace run.toml with a JSON run config
### Synopsis
[Section titled “Synopsis”](#synopsis)
Read a JSON run config payload from a file (or stdin) and make it the run.toml.
Pass "-" or omit the argument to read from stdin.
The payload replaces the whole file — jobs, profiles, .env port links and project settings alike. The run is confirmed before anything is written; pass --yes to run unattended.
Nothing is reconciled after the write: run wtm env to settle the .env files against the new configuration.
```plaintext
wtm run import [file] [flags]
```
### Examples
[Section titled “Examples”](#examples)
```plaintext
wtm run import run.json
# No confirmation, from stdin
cat run.json | wtm run import - --yes
# Then settle a worktree's .env files on it
wtm env feat/login --yes
```
### Options
[Section titled “Options”](#options)
```plaintext
-h, --help help for import
--output string Output format: text or json (default "text")
-y, --yes Replace run.toml without confirming
```
### Options inherited from parent commands
[Section titled “Options inherited from parent commands”](#options-inherited-from-parent-commands)
```plaintext
-q, --quiet Silence human output; errors and the exit code are unaffected, and --output json still emits its document
```
### SEE ALSO
[Section titled “SEE ALSO”](#see-also)
* [wtm run](/reference/wtm-run/) - Manage dev jobs (services + tasks)
# wtm run init
Configure the run module (services & tasks) for this repo
### Synopsis
[Section titled “Synopsis”](#synopsis)
Set up run.toml by detecting docker-compose files and package.json scripts and turning the selected ones into jobs.
In a TTY, opens a wizard to pick which ones to include; with --yes (or piped), auto-generates from detection. Re-running pre-fills every step from the existing run.toml: what stays checked is kept, what you uncheck is removed along with the profile entries and .env links naming it. Only jobs this wizard proposed are ever removed — one added with `wtm run job add` is never listed, so never touched. An unattended run asks nothing and removes nothing.
Ports declared in the selected compose files become per-worktree ports. A literal host port ("5432:5432") binds the same port everywhere, so wtm offers to rewrite it as "${DB_PORT:-5432}:5432" — the default keeps `docker compose up` working on its own. Declining leaves the file untouched and declares no port for it.
The names those files pin absolutely get the same treatment. A container_name, or a volume's or network's explicit name, is resolved by the Docker daemon rather than by the compose project, so COMPOSE_PROJECT_NAME never reaches it and a second worktree collides on it. wtm offers to front them with the project — a renamed volume starts empty, its data staying under the name it used to carry.
In a monorepo, a root script that starts several apps at once is asked which declared jobs it runs. wtm reads the directory a script sits in, never what its command does: the relation is declared, and it is what keeps a runner from being reported as a service that forgot its port — and from being started alongside one of its own children.
Dev servers get theirs from the env files sitting next to their package.json — a PORT (or \*\_PORT) entry in .env.local, .env, or a committed .env.example. A service nothing was found for is offered anyway: declaring its port is what keeps a second worktree from binding the same one.
wtm injects the variable, it never edits the command. When a command never mentions the port it is given, the wizard offers it for editing on the spot (`pnpm dev --port ${PORT}`) rather than reporting it once it is too late.
The mode those names are written in is asked too, because it is the one choice with a consequence outside wtm: named URLs are served by the run proxy, so they answer while `wtm run` runs the job and not when you start it yourself. A project whose author launches their own dev servers wants ports.
Every service that declares the port it listens on is then offered a name of its own — ...localhost, served by the proxy — so two worktrees stop sharing a cookie jar. A port a job only dials (DB_PORT, REDIS_PORT) is never offered: a name nothing answers under is worse than no name at all.
```plaintext
wtm run init [flags]
```
### Examples
[Section titled “Examples”](#examples)
```plaintext
# The wizard
wtm run init
# Unattended, from detection
wtm run init --yes
# Also rewrite compose host ports and link the .env port keys
wtm run init --yes --patch-compose --link-env
```
### Options
[Section titled “Options”](#options)
```plaintext
-h, --help help for init
--link-env Link the .env keys holding a declared port, so each worktree gets its own
--patch-compose Rewrite the selected compose files' literal host ports and absolute names to read a variable
--write-port-keys Write each declared port into the job's .env and its template, so an app launched by hand reads the worktree's port
-y, --yes Run unattended: auto-generate from detection; never prompt
```
### Options inherited from parent commands
[Section titled “Options inherited from parent commands”](#options-inherited-from-parent-commands)
```plaintext
-q, --quiet Silence human output; errors and the exit code are unaffected, and --output json still emits its document
```
### SEE ALSO
[Section titled “SEE ALSO”](#see-also)
* [wtm run](/reference/wtm-run/) - Manage dev jobs (services + tasks)
# wtm run job
Add, remove, or edit jobs in run.toml
### Synopsis
[Section titled “Synopsis”](#synopsis)
Manage jobs declared in /wtm/run.toml.
```plaintext
wtm run job [flags]
```
### Examples
[Section titled “Examples”](#examples)
```plaintext
wtm run job list
wtm run job add web --cmd 'pnpm dev --port ${PORT}' --cwd apps/web --port PORT=3000 --url-port PORT --yes
wtm run job edit web
wtm run job rm web
```
### Options
[Section titled “Options”](#options)
```plaintext
-h, --help help for job
```
### Options inherited from parent commands
[Section titled “Options inherited from parent commands”](#options-inherited-from-parent-commands)
```plaintext
-q, --quiet Silence human output; errors and the exit code are unaffected, and --output json still emits its document
```
### SEE ALSO
[Section titled “SEE ALSO”](#see-also)
* [wtm run](/reference/wtm-run/) - Manage dev jobs (services + tasks)
* [wtm run job add](/reference/wtm-run-job-add/) - Add a job to run.toml
* [wtm run job edit](/reference/wtm-run-job-edit/) - Edit an existing job
* [wtm run job list](/reference/wtm-run-job-list/) - List jobs from run.toml
* [wtm run job rm](/reference/wtm-run-job-rm/) - Remove a job from run.toml
# wtm run job add
Add a job to run.toml
### Synopsis
[Section titled “Synopsis”](#synopsis)
Append a job to /wtm/run.toml.
Every flag pre-fills the corresponding question, so the form opens on what was already given. --yes skips the questions altogether: \[name] and --cmd are then required, and every other field falls back to its documented default.
\--runs, --touches and --binds-no-port declare how the job relates to the others; --scope shared and the --namespace-\* flags declare a service run once for the whole repository and each worktree's namespace in it. The file is refused exactly as loading it would refuse it: a namespace only on a shared service, with both a name and a create command; --runs and --touches naming declared jobs.
\--cmd and --stop are /bin/sh lines: quotes, && and ${VAR} behave as in a terminal, so a declared port can be passed as a flag — --cmd 'pnpm dev --port ${PORT}'.
```plaintext
wtm run job add [name] [flags]
```
### Examples
[Section titled “Examples”](#examples)
```plaintext
# Answer the form
wtm run job add
# A dev server with its own port per worktree and a named URL
wtm run job add web --cmd 'pnpm dev --port ${PORT}' --cwd apps/web --port PORT=3000 --url-port PORT --yes
# A migration, which changes the data of the postgres job
wtm run job add migrate --kind task --cmd 'pnpm db:migrate' --touches postgres --yes
# One postgres for the repository, a database per worktree
wtm run job add postgres --cmd 'docker compose up -d postgres' --stop 'docker compose stop postgres' \
--scope shared --port POSTGRES_PORT=5432 --namespace-name 'app_{worktree}' \
--namespace-create scripts/db-add.sh --namespace-remove scripts/db-drop.sh --yes
```
### Options
[Section titled “Options”](#options)
```plaintext
--binds-no-port This service listens on nothing by design, so stop offering it a port
--cmd string Command to run, as a /bin/sh line
--cwd string Working directory (relative to project root)
-h, --help help for add
--kind string Job kind: service or task (default "service")
--namespace-create string Command creating the namespace, run on every start of the shared service (must be safe to rerun)
--namespace-env stringArray Extra variable for the namespace commands as KEY=VALUE, repeatable ({worktree} and {ordinal} are filled in)
--namespace-name string Name of each worktree's namespace in a shared service, e.g. app_{worktree}
--namespace-remove string Command dropping the namespace, run by wtm clean
--output string Output format: text or json (default "text")
--port stringArray Base port as NAME=PORT, repeatable (e.g. --port PORT=3000)
--runs stringArray Declared job this one starts itself, repeatable (a turbo or compose runner)
--scope string Where the job runs: shared (one instance for the whole repository) or worktree (the default, one per worktree)
--stop string Stop command, as a /bin/sh line (services only)
--touches stringArray Declared service whose data this job changes (a migration, a reset, a seed), repeatable
--url-host string Host segment to publish under, defaulting to the job's name
--url-port string Publish this declared port under a name (e.g. --url-port PORT)
-y, --yes Skip all prompts; [name] and --cmd are then required
```
### Options inherited from parent commands
[Section titled “Options inherited from parent commands”](#options-inherited-from-parent-commands)
```plaintext
-q, --quiet Silence human output; errors and the exit code are unaffected, and --output json still emits its document
```
### SEE ALSO
[Section titled “SEE ALSO”](#see-also)
* [wtm run job](/reference/wtm-run-job/) - Add, remove, or edit jobs in run.toml
# wtm run job edit
Edit an existing job
### Synopsis
[Section titled “Synopsis”](#synopsis)
Edit a job declared in /wtm/run.toml.
Pass any of --name, --cmd, --kind, --stop, --cwd, --port, --port-clear, --url-port, --url-host, --runs, --binds-no-port, --touches, --scope, --namespace-name, --namespace-create, --namespace-remove or --namespace-env to change those fields and nothing else: a flag left out keeps the field as it is, and passing an empty string clears it (--stop '' drops the stop command, --url-port '' withdraws the published name, --namespace-name '' withdraws the whole \[job.namespace]).
\--scope shared runs one instance for the whole repository, in the main checkout; --scope worktree puts it back to one per worktree. A \[job.namespace] is only accepted on a shared service and needs both a name and a create command, which runs on every start and so must be safe to run again. --runs and --touches name declared jobs; the file is refused exactly as loading it would refuse it.
\--port merges into the ports the job already declares, so one entry can be changed without rewriting the others; --port-clear empties the table. --name also rewrites what names this job elsewhere in the file: the profiles, the runners' runs, the touches, and the \[\[env_port]] and \[\[env]] links. It is refused while a worktree holds data in the job, which clean finds by its name.
With no such flag, the form opens pre-filled with the current values, and without an argument it prompts to pick from the existing jobs.
```plaintext
wtm run job edit [name] [flags]
```
### Examples
[Section titled “Examples”](#examples)
```plaintext
# The form, pre-filled
wtm run job edit web
wtm run job edit web --cmd 'pnpm dev --port ${PORT}' --yes
# Change one port, keep the others
wtm run job edit web --port PORT=3001 --yes
# Rename it everywhere run.toml names it
wtm run job edit web --name frontend --yes
```
### Options
[Section titled “Options”](#options)
```plaintext
--binds-no-port This service listens on nothing by design, so stop offering it a port (--binds-no-port=false to undo)
--cmd string Command to run, as a /bin/sh line
--cwd string Working directory relative to project root (pass '' to drop it)
-h, --help help for edit
--kind string Job kind: service or task
--name string Rename the job, updating the profiles, runs, touches, [[env_port]] and [[env]] links that name it
--namespace-create string Command creating the namespace, run on every start of the shared service (must be safe to rerun)
--namespace-env stringArray Extra variable for the namespace commands as KEY=VALUE, repeatable — replaces the table (pass '' to drop it)
--namespace-name string Name of each worktree's namespace in a shared service (pass '' to withdraw the whole [job.namespace])
--namespace-remove string Command dropping the namespace, run by wtm clean (pass '' to drop it)
--output string Output format: text or json (default "text")
--port stringArray Base port as NAME=PORT, repeatable — merged into the declared ports
--port-clear Drop every port this job declares
--runs stringArray Declared job this one starts itself, repeatable — replaces the list (pass '' to drop it)
--scope string Where the job runs: shared (one instance for the whole repository) or worktree (one per worktree)
--stop string Stop command, as a /bin/sh line (pass '' to drop it)
--touches stringArray Declared service whose data this job changes (a migration, a reset, a seed), repeatable — replaces the list (pass '' to drop it)
--url-host string Host segment to publish under (pass '' to fall back to the job's name)
--url-port string Publish this declared port under a name (pass '' to withdraw the named URL)
-y, --yes Skip all prompts; a field flag is then required
```
### Options inherited from parent commands
[Section titled “Options inherited from parent commands”](#options-inherited-from-parent-commands)
```plaintext
-q, --quiet Silence human output; errors and the exit code are unaffected, and --output json still emits its document
```
### SEE ALSO
[Section titled “SEE ALSO”](#see-also)
* [wtm run job](/reference/wtm-run-job/) - Add, remove, or edit jobs in run.toml
# wtm run job list
List jobs from run.toml
### Synopsis
[Section titled “Synopsis”](#synopsis)
List jobs declared in /wtm/run.toml.
In a TTY, opens an interactive picker. Selecting a job offers Edit or Remove. Use --output json, --yes (or pipe stdout) for a non-interactive listing.
```plaintext
wtm run job list [flags]
```
### Examples
[Section titled “Examples”](#examples)
```plaintext
wtm run job list
wtm run job list --output json
```
### Options
[Section titled “Options”](#options)
```plaintext
-h, --help help for list
--output string Output format: text or json (default "text")
-y, --yes Skip the picker; print the table instead
```
### Options inherited from parent commands
[Section titled “Options inherited from parent commands”](#options-inherited-from-parent-commands)
```plaintext
-q, --quiet Silence human output; errors and the exit code are unaffected, and --output json still emits its document
```
### SEE ALSO
[Section titled “SEE ALSO”](#see-also)
* [wtm run job](/reference/wtm-run-job/) - Add, remove, or edit jobs in run.toml
# wtm run job rm
Remove a job from run.toml
### Synopsis
[Section titled “Synopsis”](#synopsis)
Remove a job from /wtm/run.toml.
Without an argument, prompts to pick from the existing jobs; under --yes the argument is required. Fails if anything names the job — a profile, a runner's runs, a job's touches, an \[\[env_port]] or an \[\[env]] link — or if a worktree still holds data in it (a shared service's namespace, which clean finds by the job's name), unless --force is given: the references are then stripped, and that data is left for you to drop by hand.
```plaintext
wtm run job rm [name] [flags]
```
### Examples
[Section titled “Examples”](#examples)
```plaintext
# Pick the job
wtm run job rm
wtm run job rm worker --yes
# Also strip the profiles and links that name it
wtm run job rm postgres --force --yes
```
### Options
[Section titled “Options”](#options)
```plaintext
--force Remove it anyway: strip the profiles, runs, touches, [[env_port]] and [[env]] links naming it
-h, --help help for rm
--output string Output format: text or json (default "text")
-y, --yes Skip the picker; [name] is then required
```
### Options inherited from parent commands
[Section titled “Options inherited from parent commands”](#options-inherited-from-parent-commands)
```plaintext
-q, --quiet Silence human output; errors and the exit code are unaffected, and --output json still emits its document
```
### SEE ALSO
[Section titled “SEE ALSO”](#see-also)
* [wtm run job](/reference/wtm-run-job/) - Add, remove, or edit jobs in run.toml
# wtm run list
List jobs and profiles declared in run.toml
### Synopsis
[Section titled “Synopsis”](#synopsis)
Show the jobs and profiles configured for the project. In a TTY, offers an interactive picker with start/stop/logs actions.
```plaintext
wtm run list [flags]
```
### Examples
[Section titled “Examples”](#examples)
```plaintext
# Pick a job or a profile, then start, stop or read it
wtm run list
# Print the table
wtm run list --yes
wtm run list --output json
```
### Options
[Section titled “Options”](#options)
```plaintext
-h, --help help for list
--output string Output format: text or json (default "text")
-y, --yes Skip the interactive picker; print the table instead
```
### Options inherited from parent commands
[Section titled “Options inherited from parent commands”](#options-inherited-from-parent-commands)
```plaintext
-q, --quiet Silence human output; errors and the exit code are unaffected, and --output json still emits its document
```
### SEE ALSO
[Section titled “SEE ALSO”](#see-also)
* [wtm run](/reference/wtm-run/) - Manage dev jobs (services + tasks)
# wtm run logs
Attach to a job's output
### Synopsis
[Section titled “Synopsis”](#synopsis)
Open the run view on \[worktree]'s jobs — the current worktree when omitted, picked interactively when there is a terminal. --job focuses one of them; without it, every job is shown. Leaving the view detaches; the jobs keep running. Without a terminal, every job's output is written as prefixed lines instead. --output json replays each job's last 1000 lines as \[{branch, path, lines: \[{job, at, text}]}], one entry per worktree, and never attaches.
```plaintext
wtm run logs [worktree...] [flags]
```
### Examples
[Section titled “Examples”](#examples)
```plaintext
# Reopen the run view on this worktree's jobs
wtm run logs
wtm run logs feat/login --job api
# The last 1000 lines of each job, as JSON
wtm run logs feat/login --output json
```
### Options
[Section titled “Options”](#options)
```plaintext
-h, --help help for logs
--job string Focus a single job instead of showing them all
--output string Output format: text or json (default "text")
-y, --yes Skip all prompts; shows every job of the current worktree
```
### Options inherited from parent commands
[Section titled “Options inherited from parent commands”](#options-inherited-from-parent-commands)
```plaintext
-q, --quiet Silence human output; errors and the exit code are unaffected, and --output json still emits its document
```
### SEE ALSO
[Section titled “SEE ALSO”](#see-also)
* [wtm run](/reference/wtm-run/) - Manage dev jobs (services + tasks)
# wtm run open
Open a job's URL in the browser
### Synopsis
[Section titled “Synopsis”](#synopsis)
Hand a job's URL to the desktop's own opener. \[worktree] defaults to the current one, and is picked interactively when there is a terminal. A worktree publishing one URL opens it; when several jobs publish one, --job names it, and is required outside a fully interactive run — a picker never runs under a pipe, under --yes or in --output json mode.
```plaintext
wtm run open [worktree] [flags]
```
### Examples
[Section titled “Examples”](#examples)
```plaintext
wtm run open
wtm run open feat/login --job web
# The port URL instead of the named one
wtm run open feat/login --job web --raw
```
### Options
[Section titled “Options”](#options)
```plaintext
-h, --help help for open
--job string Job whose URL to open (required when several jobs publish one, outside a fully interactive run)
--output string Output format: text or json (default "text")
--raw Open the port URL (http://localhost:) instead of the named URL
-y, --yes Skip the pickers; --job is then required when several jobs publish a URL
```
### Options inherited from parent commands
[Section titled “Options inherited from parent commands”](#options-inherited-from-parent-commands)
```plaintext
-q, --quiet Silence human output; errors and the exit code are unaffected, and --output json still emits its document
```
### SEE ALSO
[Section titled “SEE ALSO”](#see-also)
* [wtm run](/reference/wtm-run/) - Manage dev jobs (services + tasks)
# wtm run profile
Add, remove, or edit profiles in run.toml
### Synopsis
[Section titled “Synopsis”](#synopsis)
Manage profiles declared in /wtm/run.toml.
```plaintext
wtm run profile [flags]
```
### Examples
[Section titled “Examples”](#examples)
```plaintext
wtm run profile list
wtm run profile add backend --jobs postgres,migrate,api --yes
wtm run profile edit backend --default --yes
wtm run profile rm backend
```
### Options
[Section titled “Options”](#options)
```plaintext
-h, --help help for profile
```
### Options inherited from parent commands
[Section titled “Options inherited from parent commands”](#options-inherited-from-parent-commands)
```plaintext
-q, --quiet Silence human output; errors and the exit code are unaffected, and --output json still emits its document
```
### SEE ALSO
[Section titled “SEE ALSO”](#see-also)
* [wtm run](/reference/wtm-run/) - Manage dev jobs (services + tasks)
* [wtm run profile add](/reference/wtm-run-profile-add/) - Add a profile to run.toml
* [wtm run profile edit](/reference/wtm-run-profile-edit/) - Edit an existing profile
* [wtm run profile list](/reference/wtm-run-profile-list/) - List profiles from run.toml
* [wtm run profile rm](/reference/wtm-run-profile-rm/) - Remove a profile from run.toml
# wtm run profile add
Add a profile to run.toml
### Synopsis
[Section titled “Synopsis”](#synopsis)
Append a profile to /wtm/run.toml.
Every flag pre-fills the corresponding question, so the form opens on what was already given. --yes skips the questions altogether: \[name] and --jobs are then required, and the profile is not the default unless --default says so.
```plaintext
wtm run profile add [name] [flags]
```
### Examples
[Section titled “Examples”](#examples)
```plaintext
# Answer the form
wtm run profile add
wtm run profile add backend --jobs postgres,migrate,api --yes
# What run up starts without --profile
wtm run profile add full --jobs postgres,api,web --default --yes
```
### Options
[Section titled “Options”](#options)
```plaintext
--default Mark this profile as the default
-h, --help help for add
--jobs strings Comma-separated existing job names, in start order
--output string Output format: text or json (default "text")
-y, --yes Skip all prompts; [name] and --jobs are then required
```
### Options inherited from parent commands
[Section titled “Options inherited from parent commands”](#options-inherited-from-parent-commands)
```plaintext
-q, --quiet Silence human output; errors and the exit code are unaffected, and --output json still emits its document
```
### SEE ALSO
[Section titled “SEE ALSO”](#see-also)
* [wtm run profile](/reference/wtm-run-profile/) - Add, remove, or edit profiles in run.toml
# wtm run profile edit
Edit an existing profile
### Synopsis
[Section titled “Synopsis”](#synopsis)
Edit a profile declared in /wtm/run.toml.
Pass --name, --jobs or --default to change those fields and nothing else: a flag left out keeps the field as it is. --jobs replaces the whole list — its order is the start order, so it is given in full — and --default=false takes the default away without handing it to another profile.
With no such flag, the form opens pre-filled with the current values, and without an argument it prompts to pick from the existing profiles.
```plaintext
wtm run profile edit [name] [flags]
```
### Examples
[Section titled “Examples”](#examples)
```plaintext
# The form, pre-filled
wtm run profile edit backend
# --jobs replaces the list, in start order
wtm run profile edit backend --jobs postgres,api --yes
wtm run profile edit backend --default --yes
```
### Options
[Section titled “Options”](#options)
```plaintext
--default Mark this profile as the default (--default=false takes it away)
-h, --help help for edit
--jobs strings Comma-separated existing job names, in start order (replaces the list)
--name string Rename the profile
--output string Output format: text or json (default "text")
-y, --yes Skip all prompts; a field flag is then required
```
### Options inherited from parent commands
[Section titled “Options inherited from parent commands”](#options-inherited-from-parent-commands)
```plaintext
-q, --quiet Silence human output; errors and the exit code are unaffected, and --output json still emits its document
```
### SEE ALSO
[Section titled “SEE ALSO”](#see-also)
* [wtm run profile](/reference/wtm-run-profile/) - Add, remove, or edit profiles in run.toml
# wtm run profile list
List profiles from run.toml
### Synopsis
[Section titled “Synopsis”](#synopsis)
List profiles declared in /wtm/run.toml.
In a TTY, opens an interactive picker. Selecting a profile offers Edit or Remove. Use --output json, --yes (or pipe stdout) for a non-interactive listing.
```plaintext
wtm run profile list [flags]
```
### Examples
[Section titled “Examples”](#examples)
```plaintext
wtm run profile list
wtm run profile list --output json
```
### Options
[Section titled “Options”](#options)
```plaintext
-h, --help help for list
--output string Output format: text or json (default "text")
-y, --yes Skip the picker; print the table instead
```
### Options inherited from parent commands
[Section titled “Options inherited from parent commands”](#options-inherited-from-parent-commands)
```plaintext
-q, --quiet Silence human output; errors and the exit code are unaffected, and --output json still emits its document
```
### SEE ALSO
[Section titled “SEE ALSO”](#see-also)
* [wtm run profile](/reference/wtm-run-profile/) - Add, remove, or edit profiles in run.toml
# wtm run profile rm
Remove a profile from run.toml
### Synopsis
[Section titled “Synopsis”](#synopsis)
Remove a profile from /wtm/run.toml.
Without an argument, prompts to pick from the existing profiles; under --yes the argument is required. Jobs referenced by the profile are left untouched.
```plaintext
wtm run profile rm [name] [flags]
```
### Examples
[Section titled “Examples”](#examples)
```plaintext
# Pick the profile
wtm run profile rm
wtm run profile rm backend --yes
```
### Options
[Section titled “Options”](#options)
```plaintext
-h, --help help for rm
--output string Output format: text or json (default "text")
-y, --yes Skip the picker; [name] is then required
```
### Options inherited from parent commands
[Section titled “Options inherited from parent commands”](#options-inherited-from-parent-commands)
```plaintext
-q, --quiet Silence human output; errors and the exit code are unaffected, and --output json still emits its document
```
### SEE ALSO
[Section titled “SEE ALSO”](#see-also)
* [wtm run profile](/reference/wtm-run-profile/) - Add, remove, or edit profiles in run.toml
# wtm run proxy
Inspect and install the redirection that serves named URLs on port 80
### Synopsis
[Section titled “Synopsis”](#synopsis)
Named job URLs carry the run proxy's port unless port 80 is redirected to it. These commands report that redirection and install or remove it.
```plaintext
wtm run proxy [flags]
```
### Examples
[Section titled “Examples”](#examples)
```plaintext
wtm run proxy status
wtm run proxy install
```
### Options
[Section titled “Options”](#options)
```plaintext
-h, --help help for proxy
```
### Options inherited from parent commands
[Section titled “Options inherited from parent commands”](#options-inherited-from-parent-commands)
```plaintext
-q, --quiet Silence human output; errors and the exit code are unaffected, and --output json still emits its document
```
### SEE ALSO
[Section titled “SEE ALSO”](#see-also)
* [wtm run](/reference/wtm-run/) - Manage dev jobs (services + tasks)
* [wtm run proxy install](/reference/wtm-run-proxy-install/) - Serve named URLs on port 80 so they drop their port
* [wtm run proxy status](/reference/wtm-run-proxy-status/) - Report what actually serves named URLs on this machine
* [wtm run proxy uninstall](/reference/wtm-run-proxy-uninstall/) - Remove the redirection and give named URLs their port back
# wtm run proxy install
Serve named URLs on port 80 so they drop their port
### Synopsis
[Section titled “Synopsis”](#synopsis)
macOS only. Install a per-user LaunchAgent: launchd binds port 80 on the loopback and hands the socket to wtm, which relays it to the run proxy. No sudo, no system file — everything lives in \~/Library/LaunchAgents and `wtm run proxy uninstall` removes it.
```plaintext
wtm run proxy install [flags]
```
### Examples
[Section titled “Examples”](#examples)
```plaintext
# See every file it would write
wtm run proxy install --dry-run
wtm run proxy install
wtm run proxy install --yes
```
### Options
[Section titled “Options”](#options)
```plaintext
--dry-run Print every file in full and write nothing
-h, --help help for install
--output string Output format: text or json (default "text")
-y, --yes Skip the confirmation
```
### Options inherited from parent commands
[Section titled “Options inherited from parent commands”](#options-inherited-from-parent-commands)
```plaintext
-q, --quiet Silence human output; errors and the exit code are unaffected, and --output json still emits its document
```
### SEE ALSO
[Section titled “SEE ALSO”](#see-also)
* [wtm run proxy](/reference/wtm-run-proxy/) - Inspect and install the redirection that serves named URLs on port 80
# wtm run proxy status
Report what actually serves named URLs on this machine
```plaintext
wtm run proxy status [flags]
```
### Examples
[Section titled “Examples”](#examples)
```plaintext
wtm run proxy status
wtm run proxy status --output json
```
### Options
[Section titled “Options”](#options)
```plaintext
-h, --help help for status
--output string Output format: text or json (default "text")
```
### Options inherited from parent commands
[Section titled “Options inherited from parent commands”](#options-inherited-from-parent-commands)
```plaintext
-q, --quiet Silence human output; errors and the exit code are unaffected, and --output json still emits its document
```
### SEE ALSO
[Section titled “SEE ALSO”](#see-also)
* [wtm run proxy](/reference/wtm-run-proxy/) - Inspect and install the redirection that serves named URLs on port 80
# wtm run proxy uninstall
Remove the redirection and give named URLs their port back
```plaintext
wtm run proxy uninstall [flags]
```
### Examples
[Section titled “Examples”](#examples)
```plaintext
wtm run proxy uninstall
wtm run proxy uninstall --yes
```
### Options
[Section titled “Options”](#options)
```plaintext
-h, --help help for uninstall
--output string Output format: text or json (default "text")
-y, --yes Skip the confirmation
```
### Options inherited from parent commands
[Section titled “Options inherited from parent commands”](#options-inherited-from-parent-commands)
```plaintext
-q, --quiet Silence human output; errors and the exit code are unaffected, and --output json still emits its document
```
### SEE ALSO
[Section titled “SEE ALSO”](#see-also)
* [wtm run proxy](/reference/wtm-run-proxy/) - Inspect and install the redirection that serves named URLs on port 80
# wtm run ps
List currently running jobs
### Synopsis
[Section titled “Synopsis”](#synopsis)
Show the jobs managed by the background daemon (name, kind, status, address, uptime, worktree). It lists every repository the daemon knows, so it works from anywhere — inside a run-initialized repository or not. To act on those jobs, open the run view with `wtm run logs`, which covers as many worktrees as you select.
```plaintext
wtm run ps [flags]
```
### Examples
[Section titled “Examples”](#examples)
```plaintext
wtm run ps
wtm run ps --output json
```
### Options
[Section titled “Options”](#options)
```plaintext
-h, --help help for ps
--output string Output format: text or json (default "text")
```
### Options inherited from parent commands
[Section titled “Options inherited from parent commands”](#options-inherited-from-parent-commands)
```plaintext
-q, --quiet Silence human output; errors and the exit code are unaffected, and --output json still emits its document
```
### SEE ALSO
[Section titled “SEE ALSO”](#see-also)
* [wtm run](/reference/wtm-run/) - Manage dev jobs (services + tasks)
# wtm run start
Start a single job
### Synopsis
[Section titled “Synopsis”](#synopsis)
Start one job of \[worktree] — the current one when omitted, picked interactively when there is a terminal. The job is named with --job; without it, a fully interactive run offers a picker. A service attaches: its output opens in the run view, and leaving the view detaches without stopping it. -d starts it and returns the prompt instead. A task always runs inline and blocks until it exits, with or without -d. Like `run up`, it reports what run.toml gets wrong before starting, checks the job's declared ports once it is up (see --no-probe and run.toml's port_probe_timeout), and asks once what to do about the jobs other worktrees are running; --exclusive and --parallel answer for one run.
```plaintext
wtm run start [worktree] [flags]
```
### Examples
[Section titled “Examples”](#examples)
```plaintext
# Pick the job to start in this worktree
wtm run start
wtm run start --job api
# A task runs inline, to the end
wtm run start feat/login --job migrate --yes
wtm run start feat/login --job api -d --yes --output json
```
### Options
[Section titled “Options”](#options)
```plaintext
-d, --detach Start the service and return immediately instead of opening its output
--exclusive Stop jobs on other worktrees before starting
--force Lift the refusal to start a job whose touches reach foreign data (see wtm run --help); other questions are still asked unless --yes
-h, --help help for start
--job string Job to start (required without a terminal or in --output json mode)
--no-probe Skip the check that each declared port was actually bound
--output string Output format: text or json (default "text")
--parallel Start without stopping other worktrees
-y, --yes Skip all prompts; --job is then required, and the other worktrees' jobs keep running unless --exclusive
```
### Options inherited from parent commands
[Section titled “Options inherited from parent commands”](#options-inherited-from-parent-commands)
```plaintext
-q, --quiet Silence human output; errors and the exit code are unaffected, and --output json still emits its document
```
### SEE ALSO
[Section titled “SEE ALSO”](#see-also)
* [wtm run](/reference/wtm-run/) - Manage dev jobs (services + tasks)
# wtm run stop
Stop one job, in one or more worktrees
### Synopsis
[Section titled “Synopsis”](#synopsis)
Stop one running job in each \[worktree] — the current one when omitted, picked interactively when there is a terminal. The job is named with --job; without it, a fully interactive run offers a picker.
```plaintext
wtm run stop [worktree...] [flags]
```
### Examples
[Section titled “Examples”](#examples)
```plaintext
wtm run stop --job api
wtm run stop feat/login fix/typo --job web --yes
```
### Options
[Section titled “Options”](#options)
```plaintext
-h, --help help for stop
--job string Job to stop (required without a terminal or in --output json mode)
--output string Output format: text or json (default "text")
-y, --yes Skip all prompts; --job is then required
```
### Options inherited from parent commands
[Section titled “Options inherited from parent commands”](#options-inherited-from-parent-commands)
```plaintext
-q, --quiet Silence human output; errors and the exit code are unaffected, and --output json still emits its document
```
### SEE ALSO
[Section titled “SEE ALSO”](#see-also)
* [wtm run](/reference/wtm-run/) - Manage dev jobs (services + tasks)
# wtm run up
Start a profile's jobs
### Synopsis
[Section titled “Synopsis”](#synopsis)
Start every job in a profile, in declared order, in each \[worktree] — the current one when omitted, picked interactively when there is a terminal. Several worktrees start concurrently and independently: one that aborts leaves the others running, and the run exits non-zero if any of them did. It starts one profile: --profile, else the default profile, else the only one declared. With several and none marked default it asks which, and fails naming --profile when it cannot ask. A run.toml declaring no profile starts every job. Once the jobs are up, each declared port is checked: a port nothing answers on is reported rather than announced as bound. It never fails the run — see --no-probe and run.toml's port_probe_timeout. Tasks block the profile and abort it on failure; services launch in the background. When another worktree is already running jobs, wtm asks once what to do about it and can remember the answer as run.toml's `concurrency`; --exclusive and --parallel override it for one run. --exclusive is refused on several worktrees, since it stops all but one. The run view opens on the jobs as they start; leaving it detaches without stopping them — the rest of the profile keeps starting, reported line by line — and -d skips the view.
```plaintext
wtm run up [worktree...] [flags]
```
### Examples
[Section titled “Examples”](#examples)
```plaintext
# The default profile, in this worktree, in the run view
wtm run up
# Two worktrees side by side, back to the prompt
wtm run up feat/login fix/typo -d
# Another profile, no prompts
wtm run up feat/login --profile backend -d --yes
# For a script or an agent
wtm run up feat/login -d --yes --output json
```
### Options
[Section titled “Options”](#options)
```plaintext
-d, --detach Start the jobs and return immediately instead of opening their output
--exclusive Stop jobs on other worktrees before starting (one worktree only)
--force Lift the refusal to start a job whose touches reach foreign data (see wtm run --help); other questions are still asked unless --yes
-h, --help help for up
--no-probe Skip the check that each declared port was actually bound
--output string Output format: text or json (default "text")
--parallel Start without stopping other worktrees
--profile string Start this profile's jobs (default: the profile marked default, or the only one declared)
-y, --yes Skip all prompts; leaves the other worktrees' jobs running unless --exclusive
```
### Options inherited from parent commands
[Section titled “Options inherited from parent commands”](#options-inherited-from-parent-commands)
```plaintext
-q, --quiet Silence human output; errors and the exit code are unaffected, and --output json still emits its document
```
### SEE ALSO
[Section titled “SEE ALSO”](#see-also)
* [wtm run](/reference/wtm-run/) - Manage dev jobs (services + tasks)
# wtm run url
Print where a job is reachable in a worktree
### Synopsis
[Section titled “Synopsis”](#synopsis)
Write a job's URL on stdout and nothing else, for $(…). \[worktree] defaults to the current one, and no picker ever opens here — an ambiguity is an error naming --job. The URL is the named URL the proxy serves (); --raw prints the port URL instead (:), which every OS resolves and no proxy has to serve.
```plaintext
wtm run url [worktree] [flags]
```
### Examples
[Section titled “Examples”](#examples)
```plaintext
wtm run url --job api
curl "$(wtm run url feat/login --job api)/health"
# The port URL, which needs no proxy
wtm run url feat/login --job api --raw
```
### Options
[Section titled “Options”](#options)
```plaintext
-h, --help help for url
--job string Job whose URL to print (required when several jobs publish one)
--output string Output format: text or json (default "text")
--raw Print the port URL (http://localhost:) instead of the named URL
```
### Options inherited from parent commands
[Section titled “Options inherited from parent commands”](#options-inherited-from-parent-commands)
```plaintext
-q, --quiet Silence human output; errors and the exit code are unaffected, and --output json still emits its document
```
### SEE ALSO
[Section titled “SEE ALSO”](#see-also)
* [wtm run](/reference/wtm-run/) - Manage dev jobs (services + tasks)
# wtm schema
Inspect or extract bundled JSON Schemas
### Synopsis
[Section titled “Synopsis”](#synopsis)
JSON Schemas describe the structure of wtm's TOML config files. Use `wtm schema dump` to write them to /wtm/schemas/ so editors can pick them up via the `#:schema` directive.
```plaintext
wtm schema [flags]
```
### Examples
[Section titled “Examples”](#examples)
```plaintext
wtm schema dump
wtm schema dump --global
```
### Options
[Section titled “Options”](#options)
```plaintext
-h, --help help for schema
```
### Options inherited from parent commands
[Section titled “Options inherited from parent commands”](#options-inherited-from-parent-commands)
```plaintext
-q, --quiet Silence human output; errors and the exit code are unaffected, and --output json still emits its document
```
### SEE ALSO
[Section titled “SEE ALSO”](#see-also)
* [wtm](/reference/wtm/) - Orchestrate git worktrees and team dev workflows from the terminal
* [wtm schema dump](/reference/wtm-schema-dump/) - Write embedded schemas to /schemas/ (or the global config's schemas/ with --global)
# wtm schema dump
Write embedded schemas to /schemas/ (or the global config's schemas/ with --global)
### Synopsis
[Section titled “Synopsis”](#synopsis)
Extract every JSON Schema bundled with this wtm binary so editors can resolve the `#:schema` directives in your TOML files. Project schemas land in /wtm/schemas/. Use --global to write the global schema next to the global wtm config, whose path `wtm run proxy status` prints.
```plaintext
wtm schema dump [flags]
```
### Examples
[Section titled “Examples”](#examples)
```plaintext
# The project schemas, beside config.toml and run.toml
wtm schema dump
# The global config's schema
wtm schema dump --global
```
### Options
[Section titled “Options”](#options)
```plaintext
--global Write the global config schema instead of the project ones
-h, --help help for dump
```
### Options inherited from parent commands
[Section titled “Options inherited from parent commands”](#options-inherited-from-parent-commands)
```plaintext
-q, --quiet Silence human output; errors and the exit code are unaffected, and --output json still emits its document
```
### SEE ALSO
[Section titled “SEE ALSO”](#see-also)
* [wtm schema](/reference/wtm-schema/) - Inspect or extract bundled JSON Schemas
# wtm shell-init
Generate shell integration function
### Synopsis
[Section titled “Synopsis”](#synopsis)
Output a shell function to eval in your rc file. Usage: eval "$(wtm shell-init)"
```plaintext
wtm shell-init [flags]
```
### Examples
[Section titled “Examples”](#examples)
```plaintext
# zsh or bash: add this line to ~/.zshrc or ~/.bashrc
eval "$(wtm shell-init)"
# fish: add this line to config.fish
wtm shell-init | source
```
### Options
[Section titled “Options”](#options)
```plaintext
-h, --help help for shell-init
```
### Options inherited from parent commands
[Section titled “Options inherited from parent commands”](#options-inherited-from-parent-commands)
```plaintext
-q, --quiet Silence human output; errors and the exit code are unaffected, and --output json still emits its document
```
### SEE ALSO
[Section titled “SEE ALSO”](#see-also)
* [wtm](/reference/wtm/) - Orchestrate git worktrees and team dev workflows from the terminal
# wtm sync
Rebase selected worktrees onto their parent, in cascade
### Synopsis
[Section titled “Synopsis”](#synopsis)
Rebase one or more managed worktrees onto their parent. Pass branch names to target specific worktrees, --all to sync every worktree, or no arguments to pick interactively. The base branch is fetched and fast-forwarded first, then each selected worktree is rebased onto its parent in topological order (parents before children). The cascade is local; on a conflict the branch is left clean (rebase aborted) and its selected descendants are skipped. Pass --keep-conflict to leave a conflicting rebase in progress in its worktree for manual resolution instead of aborting. A parent no step covers — a branch with no worktree, or one left out of the selection — is never refreshed by the cascade; when it is behind its remote you are offered to fast-forward it first (--ff-parents / --no-ff-parents). After a successful cascade, optionally force-push (with lease) the rebased branches.
```plaintext
wtm sync [branch...] [flags]
```
### Examples
[Section titled “Examples”](#examples)
```plaintext
# Pick the worktrees to rebase
wtm sync
# Preview the whole cascade
wtm sync --all --dry-run
# Rebase a stack, then force-push it (with lease)
wtm sync feat/login feat/login-ui --yes --push
# Every worktree, locally only
wtm sync --all --yes --no-push --output json
```
### Options
[Section titled “Options”](#options)
```plaintext
--all Sync every managed worktree
--base string Base branch to sync from (defaults to config or detected base)
--dry-run Preview the cascade without rebasing or pushing
--ff-parents Fast-forward the parents the cascade does not cover (no worktree, or left out of the selection) before rebasing onto them; no-op with --dry-run
-h, --help help for sync
--keep-conflict Leave a conflicting rebase in progress in its worktree instead of aborting
--no-ff-parents Never fast-forward those parents; rebase onto them as they are
--no-push Rebase locally only; never push
--output string Output format: text or json (default "text")
--push Force-push (with lease) rebased branches without prompting
-y, --yes Skip all prompts; resolve every decision from flags and safe defaults (requires branch args or --all; use --push to push)
```
### Options inherited from parent commands
[Section titled “Options inherited from parent commands”](#options-inherited-from-parent-commands)
```plaintext
-q, --quiet Silence human output; errors and the exit code are unaffected, and --output json still emits its document
```
### SEE ALSO
[Section titled “SEE ALSO”](#see-also)
* [wtm](/reference/wtm/) - Orchestrate git worktrees and team dev workflows from the terminal
# wtm tree
Show the worktree forest (parent → child)
### Synopsis
[Section titled “Synopsis”](#synopsis)
Render the forest of managed worktrees, parents above their children, with the orchestration signals that matter for a stacked-branch workflow: commits ahead (↑N), uncommitted changes (⚠ dirty), and "needs sync" when a parent has moved and the child must be rebased. Parents with no worktree appear as greyed virtual roots.
\--with-prs adds PR numbers and merged/closed markers (fetched eagerly). --output json emits the structured tree for agents; --output mermaid emits a flowchart to paste into a PR or Notion.
```plaintext
wtm tree [flags]
```
### Examples
[Section titled “Examples”](#examples)
```plaintext
wtm tree
# With PR numbers and merged/closed markers
wtm tree --with-prs
# A flowchart to paste into a PR description
wtm tree --output mermaid
```
### Options
[Section titled “Options”](#options)
```plaintext
-h, --help help for tree
--output string Output format: text, json or mermaid (default "text")
--with-prs Include GitHub PR info (open/merged/closed; fetched eagerly)
```
### Options inherited from parent commands
[Section titled “Options inherited from parent commands”](#options-inherited-from-parent-commands)
```plaintext
-q, --quiet Silence human output; errors and the exit code are unaffected, and --output json still emits its document
```
### SEE ALSO
[Section titled “SEE ALSO”](#see-also)
* [wtm](/reference/wtm/) - Orchestrate git worktrees and team dev workflows from the terminal
# wtm ui
Open the worktree dashboard
### Synopsis
[Section titled “Synopsis”](#synopsis)
Open a full-screen dashboard of the repository's worktrees. The Worktrees tab lists them with their git state against both the base branch and origin, and their pull requests; the Tree tab lays the same worktrees out as the parent-child forest `wtm tree` prints; the Services tab gathers every worktree the run daemon holds something up in, with the addresses its jobs answer on. `n` creates a worktree; right-click a row (or press `m`) to reparent, sync, or delete it; `a` opens the actions that run over several worktrees at once, syncing or reparenting a selection of them; `L` reads a job's logs in the detail panel. The list follows every worktree created, moved or removed, whoever did it, as `wtm events` reports it; its local git state is re-read every 20 seconds, when the terminal regains focus and after each action; the detail panel reloads when the selection changes or an operation touches it, and pull requests load once. Nothing is fetched on its own: `r` fetches the remote and refreshes all of it. Press `?` for the key reference.
```plaintext
wtm ui [flags]
```
### Examples
[Section titled “Examples”](#examples)
```plaintext
# Press ? inside for the key reference
wtm ui
```
### Options
[Section titled “Options”](#options)
```plaintext
-h, --help help for ui
--output string Output format: text or json (default "text")
```
### Options inherited from parent commands
[Section titled “Options inherited from parent commands”](#options-inherited-from-parent-commands)
```plaintext
-q, --quiet Silence human output; errors and the exit code are unaffected, and --output json still emits its document
```
### SEE ALSO
[Section titled “SEE ALSO”](#see-also)
* [wtm](/reference/wtm/) - Orchestrate git worktrees and team dev workflows from the terminal
# wtm upgrade
Update wtm to the latest release
### Synopsis
[Section titled “Synopsis”](#synopsis)
Bring wtm up to the latest published release, doing the right thing for how it was installed. A standalone binary is replaced in place after its SHA256 is verified against the release checksums. A Homebrew or `go install` binary is handed to that tool instead — replacing a package-manager-owned binary would desynchronize it. A binary built from source is refused, since no published release corresponds to it.
This updates the CLI itself, not your worktrees — that is `wtm sync`.
\--check reports what is available without changing anything. --yes skips the confirmation (required with --output json). --version pins an explicit release and applies to standalone installs only.
```plaintext
wtm upgrade [flags]
```
### Examples
[Section titled “Examples”](#examples)
```plaintext
# Is there a newer release?
wtm upgrade --check
wtm upgrade
wtm upgrade --yes --output json
```
### Options
[Section titled “Options”](#options)
```plaintext
--check Report whether a newer release exists without installing anything
-h, --help help for upgrade
--output string Output format: text or json (default "text")
--version string Install a specific release instead of the latest (standalone installs only)
-y, --yes Skip the confirmation prompt
```
### Options inherited from parent commands
[Section titled “Options inherited from parent commands”](#options-inherited-from-parent-commands)
```plaintext
-q, --quiet Silence human output; errors and the exit code are unaffected, and --output json still emits its document
```
### SEE ALSO
[Section titled “SEE ALSO”](#see-also)
* [wtm](/reference/wtm/) - Orchestrate git worktrees and team dev workflows from the terminal
# wtm version
Print wtm's version and the versions of its machine contracts
### Synopsis
[Section titled “Synopsis”](#synopsis)
Print the version of this wtm binary, the same line as `wtm --version`. With --output json it also gives the version of each contract an integration reads, so a host can tell it is talking to a wtm it understands before relying on it: `events` is the schema version of `wtm events`. New keys are added as new contracts appear; a reader ignores the ones it does not know.
```plaintext
wtm version [flags]
```
### Examples
[Section titled “Examples”](#examples)
```plaintext
wtm version
# What a host checks before reading wtm events
wtm version --output json | jq .events
```
### Options
[Section titled “Options”](#options)
```plaintext
-h, --help help for version
--output string Output format: text or json (default "text")
```
### Options inherited from parent commands
[Section titled “Options inherited from parent commands”](#options-inherited-from-parent-commands)
```plaintext
-q, --quiet Silence human output; errors and the exit code are unaffected, and --output json still emits its document
```
### SEE ALSO
[Section titled “SEE ALSO”](#see-also)
* [wtm](/reference/wtm/) - Orchestrate git worktrees and team dev workflows from the terminal
# Developer documentation
Reference documentation for people (and agents) working **on** wtm. It describes the code as delivered, not the design that preceded it.
> The rest of `docs/` is **generated** by `tools/gendocs` from the Cobra command tree (`make docs`) and must never be hand-edited. `docs/dev/` is hand-written and is the only manual content under `docs/`; gendocs only writes `wtm_*.md` at the root of `docs/`, so this subdirectory survives a regeneration untouched.
| Document | What it covers |
| -------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [architecture.md](/0-28/dev/architecture/) | The layers, who may call whom, and what each interdiction buys |
| [flow-layer.md](/0-28/dev/flow-layer/) | `internal/flow/` — the three seams, the step model, the flow diagrams, one flow across three surfaces |
| [adding-a-mutation-command.md](/0-28/dev/adding-a-mutation-command/) | End-to-end recipe for a new worktree-mutating command |
| [output.md](/0-28/dev/output/) | What a command prints: the one question a block has to answer, the frame and the accent bar, the four levels, the two shapes of a conclusion, the glyph vocabulary, `--quiet` |
| [run-addressing.md](/0-28/dev/run-addressing/) | Named URLs: proxy vs redirection vs public port, and what `addressing` writes into a `.env` |
| [shared-services.md](/0-28/dev/shared-services/) | `scope = "shared"`: one instance for the repository, one namespace per worktree, and why the job table is the reference count |
For the coding standards themselves (immutability, struct params, constants, comment density), see [`CLAUDE.md`](https://github.com/LucasPcq/wtm/blob/v0.28.0/CLAUDE.md) and the `go-cli` skill in `.claude/skills/go-cli/SKILL.md`.
# Adding a worktree-mutating command
A *mutation command* is one that changes worktree state: it creates, removes, moves or rewrites something, and therefore has questions to ask, safety refusals to honor, and two bypass axes to expose. Every new one goes through `internal/flow/` — the model in [flow-layer.md](/0-28/dev/flow-layer/).
A read-only command (`list`, `tree`, `resolve`) needs none of this: parse flags, call the service, hand the result to `output/`.
## 1. Declare the vocabulary in `domain/`
[Section titled “1. Declare the vocabulary in domain/”](#1-declare-the-vocabulary-in-domain)
Constants first, so nothing downstream invents a string:
* `domain.CmdSplit` for the command name, `domain.FlagInto` for each new flag (`internal/domain/constants.go`).
* Every user-visible label, description, recap field and skip reason. A step's prose lives in `constants.go`, not as a literal in the flow.
* A sentinel in `internal/domain/errors.go` for each required selection that has no safe default, worded so it names the flag: `ErrSplitTargetRequired`.
* If a surface will have to schedule it, an `OpKind` constant.
## 2. Put the decisions in `rules/`
[Section titled “2. Put the decisions in rules/”](#2-put-the-decisions-in-rules)
Anything that is a *decision over data* — is this safe, which of these applies, what is the default here — is a pure function in `internal/rules/`, taking domain types and returning domain types. No I/O, no writing. It is then testable as a table and callable from the flow, the service and any surface.
## 3. Write the flow package
[Section titled “3. Write the flow package”](#3-write-the-flow-package)
`internal/flow/split/split.go` — the run:
```go
type Request struct {
Branch string
Into string
Force bool // the safety axis, if the command has refusals to lift
}
type Outcome struct {
Branch string
Result domain.SplitResult
Aborted bool
}
type Presenter interface {
flow.Presenter
Split(Outcome) error // the typed conclusion, one per command
}
type Params struct {
Context flow.Context
Request Request
Prompter flow.Prompter
Presenter Presenter
}
func Run(params Params) (Outcome, error) {
f := &splitFlow{ctx: params.Context, request: params.Request,
prompter: params.Prompter, presenter: params.Presenter}
return f.run()
}
```
Rules that are not negotiable:
* The package imports **only** `internal/service`, `internal/rules`, `internal/domain` and the stdlib. Never cobra, bubbletea, lipgloss, `internal/output`, `internal/tui`, `internal/config` or `internal/commands`. If you need something only `infra/` has, add a thin wrapper in `service/` — as `worktree.FindByBranch` does.
* `Request` carries **no `--yes` and no `--output`**. `--force` does belong there.
* Errors are returned. A user abort is `presenter.Notice(flow.AbortedNotice)` followed by `Outcome{Aborted: true}, nil`.
* Long work goes through `presenter.Stage`; hook output through `presenter.HookPhase`; a line inside an ongoing phase through `presenter.Status`. The flow never prints.
`internal/flow/split/steps.go` — the questions:
```go
const (
KeyBranch = "split.branch"
KeyInto = "split.into"
KeyRecap = "split.recap"
)
func (f *splitFlow) session() flow.Session {
return flow.Session{
ErrLabel: domain.SplitWizardErrLabel,
Presets: flow.NewAnswers(map[string]string{
KeyBranch: f.request.Branch,
KeyInto: f.request.Into,
}),
Steps: []flow.Step{f.branchStep(), f.intoStep(), f.recapStep()},
}
}
```
For **each** step, decide its `Resolve` — that is the bypass taxonomy, and it is the step's own business:
| The step is… | `Resolve` |
| ----------------------------------------- | ---------------------------------------------------------------------------- |
| a decision with a safe default | returns that `Answer`. Never destructive. |
| a required selection with no safe default | returns an error naming the flag, usually the domain sentinel |
| answerable only by a human | omitted entirely — `flow.Unattended` then refuses, naming `Label` and `Flag` |
And the rest of the fields:
* `Skip func(Answers) (skip bool, reason string)` when the step can become irrelevant. The reason is user-visible; put it in `constants.go`.
* `Build` for content derived from earlier answers, `Load` when deriving it does I/O (plus `LoadingMessage`). Never do slow work in `Build`.
* `Summarize` when the raw answer value is not what the user should read back.
* `Flag` so a refusal can name the flag that would have answered the step.
* `Blockers` on the `StepContent` of a step whose dangerous option is gated by safety refusals — one entry per refusal, never folded into the prose, so a surface can have them lifted one at a time.
* The **recap is always the last step** and always unconditional. Its `Build` names every part of the plan, including the parts a flag resolved — read the value from `Answers`, which returns presets too. A flag must never make a line disappear.
If a surface may run several of these at once, declare how:
```go
func Operation() flow.Operation {
return flow.Operation{Kind: domain.OpKindSplit, Mode: flow.ModeBlocking, TargetKey: KeyBranch}
}
```
## 4. Wire the command
[Section titled “4. Wire the command”](#4-wire-the-command)
`internal/commands/wt/split.go` holds flag wiring and nothing else:
```go
func runSplit(cmd *cobra.Command, args []string) error {
into, _ := cmd.Flags().GetString(domain.FlagInto)
force, _ := cmd.Flags().GetBool(domain.FlagForce)
yes, _ := cmd.Flags().GetBool(domain.FlagYes)
format, _ := cmd.Flags().GetString(domain.FlagOutput)
if format == domain.OutputJSON && !yes {
return domain.ErrSplitJSONNeedsYes
}
dir, err := os.Getwd()
if err != nil {
return fmt.Errorf("get working directory: %w", err)
}
config, err := shared.LoadConfig(cmd, dir)
if err != nil {
return err
}
// The one place --yes is read: which Prompter gets installed.
interactive := rules.IsHumanFormat(format) && !yes && term.IsTerminal(int(os.Stdin.Fd()))
_, err = splitflow.Run(splitflow.Params{
Context: flowContext(config),
Request: splitflow.Request{Branch: branchName, Into: into, Force: force},
Prompter: flowPrompter(flowPrompterParams{Interactive: interactive}),
Presenter: splitPresenter{cliPresenter: newPresenter(cmd, format)},
})
return err
}
```
Flag help strings are uniform:
* `--yes` / `-y` — *"Skip all prompts; resolve every decision from flags and safe defaults (…)"*
* `--force` — *"Lift safety refusals (…); still asks to confirm unless --yes"*
Register the command in its parent group and give it a `GroupID` (`domain.CmdGroup*`), or it renders under a stray "Additional Commands" heading.
## 5. Add the CLI presenter
[Section titled “5. Add the CLI presenter”](#5-add-the-cli-presenter)
In `internal/commands/wt/presenter.go`, next to `createPresenter` and `cleanPresenter`:
```go
type splitPresenter struct{ cliPresenter }
func (p splitPresenter) Split(outcome splitflow.Outcome) error {
if p.format == domain.OutputJSON {
return output.WriteSplitJSON(p.cmd.OutOrStdout(), outcome.Result)
}
output.Frame(p.cmd.OutOrStdout(), func() {
output.FormatSplitResult(p.cmd.OutOrStdout(), /* … */)
})
return nil
}
```
`cliPresenter` already implements `Stage`, `HookPhase`, `Notice` and `Status` — embed it and add only the typed conclusion. The frame is applied **exactly once**, here; JSON and machine output are never framed.
## 6. Test it
[Section titled “6. Test it”](#6-test-it)
* **Flow tests** in the flow package, with `flowtest.ScriptedPrompter` and `flowtest.Recorder`. Assert on the answers that were asked (`AskedKeys()`), on the `StepContent` a step produced (the recap prose is user-visible behavior), and on the outcome.
* **Unattended tests** with `flow.Unattended{}` directly: each required selection refuses and names its flag, each defaulted decision lands on the safe value.
* **Integration tests** at the Cobra level (`gittest.InitRepo` + `WTM_PROJECT_DIR` / `WTM_STATE_DIR`) for the two axes end to end: `--yes` without the required flag errors, `--force` alone still confirms, `--output json` requires `--yes`.
## 7. Documentation
[Section titled “7. Documentation”](#7-documentation)
1. `make docs` — regenerates `docs/`, never hand-edited.
2. Add the command to the `README.md` overview table, in the same group as the root `--help`.
3. Update the agent skill (`internal/commands/agents/assets/using-wtm/`, the reference file of the command's topic) if the agent-facing surface changed (a new command, a new flag, a changed JSON shape, changed failure/abort semantics).
4. Run the `build-validator` subagent. Step 6 fails the run if `internal/flow/` gained a forbidden import.
## Optional: make it work in the dashboard
[Section titled “Optional: make it work in the dashboard”](#optional-make-it-work-in-the-dashboard)
Nothing in the flow changes. In `internal/tui/dashboard/actions.go`, add a `startSplit` that checks `busyReason`, calls `beginOp(splitflow.Operation())`, and launches `splitflow.Run` in a `tea.Cmd` with the dashboard's `prompter` and a presenter embedding `dashboard.presenter` plus the typed conclusion. If the flow uses a step kind the dashboard's modal cannot render, that is the only work left — and `flowui` will refuse an unknown kind rather than guess, so you will hear about it immediately.
# Architecture — the layers and what they buy
wtm is a Cobra CLI with two interactive surfaces (an inline wizard and the `wtm ui` dashboard) over one set of git operations. The layering exists so that a command's *flow* — the order of its questions, its safety checks, its service calls — is written once and can be replayed by either surface.
## The map
[Section titled “The map”](#the-map)
```plaintext
cmd/ entry points, cobra setup only
internal/
commands/ flag wiring, delegates to flow/service (zero business logic)
ui/ `wtm ui`: refuses JSON and a missing TTY, hands off to tui/dashboard
domain/ types, errors, constants only (no methods, no functions)
rules/ pure functions (stdlib + domain only, no I/O)
config/ load & validate config.toml + run.toml
flow/ the flow of each command, surface-independent
decide/ branch/env decisions shared by the create-like flows
create/ `wtm create`: the run + its questions
clean/ `wtm clean`: the run + its questions
runlogs/ `wtm run`: the jobs, their live streams, the start sequence
service/ impure orchestration (git exec, I/O, hooks)
output/ format and print results (zero decision logic)
styles/ all Lipgloss styles
tui/ Bubbletea models (rendering only)
flowui/ runs a flow.Session as the CLI wizard
dashboard/ `wtm ui`, the second surface over flow/
runview/ `wtm run up`/`logs`, a VT emulator per job
infra/ I/O, git exec, filesystem wrappers
```
## Who may call whom
[Section titled “Who may call whom”](#who-may-call-whom)
```mermaid
flowchart TD
commands["commands/"] --> flow["flow/"]
commands --> output["output/"]
commands --> tui["tui/"]
commands --> config["config/"]
tui --> flow
flow --> service["service/"]
flow --> rules["rules/"]
service --> infra["infra/"]
service --> rules
rules --> domain["domain/"]
flow --> domain
output --> styles["styles/"]
tui --> styles
```
Every arrow that is *missing* is the point:
| Interdiction | What it buys |
| ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| `commands/` has no business logic | A command is readable as flags in, one call out. Changing the flow never means editing flag parsing. |
| `domain/` holds types, errors and constants only | Nothing can acquire a dependency by hiding behind a method on a shared type. |
| `rules/` imports only stdlib + `domain/` | Decisions stay testable with no repo, no network, no temp dir. `rules.DecidePush` is a table test, not an integration test. |
| `service/` never imports cobra, bubbletea or lipgloss | The git operations are callable from a test, a flow, a daemon — anything that is not a terminal. |
| `output/` and `tui/` hold no decision logic | Two surfaces can render the same run without disagreeing about what it means. |
| `styles/` is the only package instantiating `lipgloss.Style` | A theme change is one file. |
| `flow/` imports only `service/`, `rules/`, `domain/` and the stdlib | The flow cannot grow a dependency on the surface that runs it. This is what makes a second surface possible at all — see below. |
`flow/` cannot reach `infra/` either. When a flow needs something only `infra/` has, the fix is a thin `service/` wrapper, not an exception: `worktree.FindByBranch` and `worktree.ListAll` exist for exactly that reason.
The `flow/` import rule is checked mechanically by the `build-validator` subagent (step 6) rather than left to review.
## The founding observation: seven closures
[Section titled “The founding observation: seven closures”](#the-founding-observation-seven-closures)
Before this layering existed, `internal/commands/wt/*.go` did three things at once: read the flags, run the flow itself, **and** hand the TUI closures that called back into the service. The TUI is forbidden from importing `service/`, so the command passed it functions instead:
| Closure injected into the TUI | Command | What it called back into |
| ----------------------------- | ------------------- | --------------------------------------------- |
| `SourceUpdate` | `create`, `extract` | `branch.Divergence` |
| `Target` | `create`, `extract` | `branch.Target` |
| `EnvFallback` | `create`, `extract` | `shared.EnvParentFallbackApplies` |
| `Check` | `clean` | `worktree.Check` |
| `ReparentPreview` | `clean` | `worktree.PlanCleanReparent` |
| `PlanPreview` | `sync` | `worktree.PlanSync` + `output.SprintSyncPlan` |
| `LoadFiles` | `extract` | `infra.ListModifiedFiles` |
The rule was respected and the architecture was still defeated: the service call happened on the TUI's goroutine, at the TUI's whim, with the command as a courier. Worse, the flow lived on both sides of that boundary — the dashboard could not replay it without duplicating it.
`flow/` **is allowed** to call the service. Those closures become hooks carried by the step declaration itself (`Skip`, `Build`, `Load`) and the courier disappears. That is the gain that justifies the refactor independently of the dashboard: `create` and `clean` inject nothing today.
The closures have not all gone yet, because not every command has migrated: `checkout` still injects `EnvFallback`. `prune`'s `ReparentPreview` and `sync`'s `PlanPreview` both went with their migration — a flow calls `rules.FinalizePrunePlan` and `rules.SprintSyncPlan` directly, and `internal/tui/syncpicker` (the package `PlanPreview` was injected into) no longer exists. And `internal/commands/wt/create.go` still holds `sourceUpdatePrompt`, `envFallbackPrompt` and `memoizedTarget` as thin adapters over `internal/flow/decide` — not for `create`, which no longer uses them, but for `wtm extract`, which embeds create's Bubbletea wizard as a sub-flow. They go with its migration (LUC-182).
## The run module — a flow that asks nothing
[Section titled “The run module — a flow that asks nothing”](#the-run-module--a-flow-that-asks-nothing)
`internal/flow/runlogs` is the second shape a flow takes. `create` and `clean` ask questions and need a `Prompter`; a run has none to ask — it *reports*. So the seam is made of three types instead:
* **`runlogs.Board`** — the worktree's jobs as a surface reads them: `Jobs()` (a `JobView` per declared or running job), `Refresh()`, `Attach()` for a live `Stream`, and `History()` for what a job left in its log file. A surface never speaks to `service/process`.
* **`runlogs.Stream`** — one attached job: raw chunks in (escape sequences included, an emulator needs them untouched), keystrokes and a PTY resize out.
* **`runlogs.Run(ctx, RunParams)`** — a profile's start sequence, reporting each step to a `Sink` as an `Event`/`Phase`. It returns an `Outcome`, never an error: what a partial state is worth — an exit code, a report, a JSON entry — belongs to the surface. Cancelling `ctx` ends the *reporting*, not the jobs: that is what a detach is.
Three surfaces consume it, chosen by one pure rule (`rules.DecideRunSurface`, which needs a terminal, a human format and no `-d` before it picks the view):
| Surface | Who | What it does with the seam |
| ---------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------- |
| `internal/tui/runview` | a terminal | full screen, one VT-emulated pane per job, tmux-style focus; returns its recap for the command to frame |
| `output.RunPrinter` | `-d`, a pipe, CI | renders each `Event` as a line on stdout/stderr |
| `output.WriteRunOutcomeJSON` | `--output json` | the array of job results, with the failing job's `output` and `exit_code` |
Everything a job needs to know about *which* worktree it belongs to is resolved by the client and travels down the seam beside `WorkDir` and `LogDir`: `RunParams.Env` → `StartRequest.Env` → `process.Request.Env` → `cmd.Env`. It cannot be inherited — the daemon is global, outlives the command that forked it, and its own environment belongs to whichever worktree happened to start it. `service/worktree.EnsureOrdinal` is what gives the worktree the stable number those variables derive from, and `service/worktree.JobEnv`/`BranchEnv` assemble them; the daemon keeps the resolved map on the `ManagedJob` so the job's stop command runs in the same environment its start did.
`internal/commands/run/surface.go` is the whole wiring: open the seam, build the starter, switch on the rule. The one thing left in the command is `handleConcurrentJobs` — the question `run up` asks about another worktree's jobs. It is a `flow.Prompter` question in everything but name, and `runlogs` has no Prompter; it stays put until the `--exclusive`/`--parallel` axis is reopened, which worktree isolation may remove entirely.
## Worktree ports and the `.env` — a terminal transformation, not a source
[Section titled “Worktree ports and the .env — a terminal transformation, not a source”](#worktree-ports-and-the-env--a-terminal-transformation-not-a-source)
Two modules meet on the `.env` files, and the order they meet in is the whole design.
`internal/service/env` reconciles a worktree's `.env` against a **cascade of value sources** — the parent worktree, then main, then the committed template. `internal/rules/jobports.go` resolves the **host ports** a worktree binds: the base declared in `run.toml` plus that worktree's offset. A `[[env_port]]` link says a `.env` key carries one of those ports, whether alone (`DB_PORT=5432`) or buried in a URL (`DATABASE_URL=postgres://…@localhost:5432/app`).
The tempting move is to make the resolved port a fourth value source. It is wrong, and expensively so. The sources all hold *another* worktree's port — main's, or the parent's — so in `EnvModeRefresh` the key lands in `EnvKeyConflict` between two spellings of the same setting, and `--on-conflict overwrite` dutifully restores main's port, undoing the isolation on every run.
So the port is applied **after** the merge, once, in `settleEnvPorts`, and the diff is taught to compare *modulo the offset*:
| Piece | Where | What it does |
| ------------------------------------------ | ------- | ---------------------------------------------------------------------------------------------- |
| `rules.PlanEnvPorts` | pure | resolves every link against the value on disk; only a base found **exactly once** is rewritten |
| `rules.ReduceEnvPortValue` | pure | rewinds any worktree's port to the base, so `5442` and `5432` compare equal |
| `rules.DiffEnv` (`PortBases`, `PortBlock`) | pure | the single comparison site, in `classifyKey.differ` |
| `env.ApplyEnvPorts` | service | the write, after every file is reconciled |
Two consequences worth keeping:
* **The reduction is modular, not subtractive.** Under the `parent` strategy the source value comes from another worktree whose offset the reader never learns, so `ReduceEnvPortValue` looks for *a number of the shape `base + k×block`* rather than for one known value. A value with no such number, or with two, is left alone — reducing on a guess would hide a real conflict.
* **Every match is bounded by digit boundaries.** Without them base `5432` matches inside `54321` and the substitution silently corrupts the value, which is the exact failure the feature exists to prevent.
The cross-file check has to live outside `config.LoadRun`: that loader only ever sees `run.toml` and validates what `run.toml` can answer for alone. Whether a link names a configured env target needs `config.toml` too, so `rules.ValidateEnvPortTargets` is called where both are in hand — `service/worktree.ResolveEnvPorts`.
**Where the question is put, on a worktree being created.** `internal/flow/envports.Settle` runs after `worktree.Create` — it needs the files to exist — but it does not *decide* there. The decision is the worktree's **isolation**, a step of the run that provisions those files (`create.KeyIsolation`, `components.IsolationStep` for the wizards of `extract` and `checkout`), skipped whole when `rules.IsolationApplies` finds nothing in `run.toml` to isolate. `worktree.Create` records the answer in `meta.json` before any hook runs, since a hook reads the ports it decides.
**One choice, read by both halves.** Isolation is not a port-pass option; it is what the worktree *is*, and two readers act on it:
| Reader | Isolated | Verbatim |
| ---------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- |
| `service/worktree.ResolveEnvPorts` — every `.env` writer (create, extract, checkout, `wtm env`, the addressing switch) | links, identity and `[[env]]` values resolved and written | resolves to nothing: the file stays as copied |
| `service/worktree.BranchEnv` — every job and hook | `WTM_PORT_OFFSET = ordinal × block`, `COMPOSE_PROJECT_NAME` derived (the main's without its branch) | offset 0, `COMPOSE_PROJECT_NAME` left to the `.env`, `WTM_ISOLATION=verbatim` |
| `service/process.runNamespace` — the daemon | carves the worktree's namespace | carves nothing (read from `WTM_ISOLATION`: the daemon never reads metadata) |
They used to be separate: a "keep the ports" answer left the `.env` on its source's ports while the daemon still shifted the jobs, so a front read one port and its back bound another, and the worktree quietly talked to its source. Anything in between the two columns is incoherent by construction, which is why there is no third answer and no `Rewrite` flag any more. The cost of verbatim is that it shares its source's ports; `flow/run/up` measures that (`rules.PortClashes`) and turns the concurrency question into stop-the-other-or-don't-start rather than letting a bind fail. `wtm env --isolation` switches an existing worktree, and its recap's second action records the worktree verbatim rather than skipping the port pass once.
## What is migrated, and what is not
[Section titled “What is migrated, and what is not”](#what-is-migrated-and-what-is-not)
| Command | Flow lives in | Surfaces |
| ---------------------------------------- | -------------------------------------------------------------------- | --------------------------------- |
| `create` | `internal/flow/create` | CLI wizard, unattended, dashboard |
| `clean` | `internal/flow/clean` | CLI wizard, unattended, dashboard |
| `reparent` | `internal/flow/reparent` | CLI wizard, unattended, dashboard |
| `prune` | `internal/flow/prune` | CLI wizard, unattended, dashboard |
| `sync` | `internal/flow/sync` | CLI wizard, unattended, dashboard |
| `extract`, `relocate`, `checkout`, `env` | `internal/commands/wt/*.go` + their `internal/tui/*` wizard packages | CLI only |
Unmigrated commands still follow the old model, and the parts of the `go-cli` skill that describe `components.Step` wizards still apply to them. A **new** mutation command goes through `flow/` — see [adding-a-mutation-command.md](/0-28/dev/adding-a-mutation-command/).
# internal/flow/ — how a command runs
A *flow* is everything a command does between "the flags are parsed" and "the result is printed": the questions it asks, in what order, which ones it may skip, the safety checks, the service calls, the phases it reports. It is written once, in `internal/flow//`, and three different surfaces can run it.
This document describes the code **as delivered**. Where it says *projected*, nothing is implemented yet.
* [What is delivered today](#what-is-delivered-today)
* [The shape of a flow](#the-shape-of-a-flow)
* [The three seams](#the-three-seams)
* [The step model](#the-step-model)
* [Command flow diagrams](#command-flow-diagrams)
* [One flow, three surfaces](#one-flow-three-surfaces)
* [Unattended resolution and the two axes](#unattended-resolution-and-the-two-axes)
* [Hook output, from the service writer to the dashboard panel](#hook-output-from-the-service-writer-to-the-dashboard-panel)
* [Testing a flow](#testing-a-flow)
* [sync — the decisions this migration settled](#sync--the-decisions-this-migration-settled)
* [Known gaps](#known-gaps)
## What is delivered today
[Section titled “What is delivered today”](#what-is-delivered-today)
| | Status |
| --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `wtm create` | migrated — `internal/flow/create` |
| `wtm clean` | migrated — `internal/flow/clean` |
| `wtm reparent` | migrated — `internal/flow/reparent` |
| `wtm prune` | migrated — `internal/flow/prune` |
| `wtm sync` | migrated — `internal/flow/sync` |
| `wtm run up\|down\|start\|stop\|logs` | migrated — `internal/flow/run/`, over the questions in `internal/flow/run/target` and the daemon binding in `internal/flow/run/seam` (LUC-193) |
| `wtm run ps` | not a flow: it reads the daemon's index and prints it, and asks nothing |
| `wtm run list` | migrated — `internal/flow/run/list` answers which entry was picked and what to do to it; `internal/commands/run/dispatch.go` runs that action through the flow it already has for it (LUC-217) |
| `wtm run job add\|edit\|rm\|list` | migrated — `internal/flow/run/job` (LUC-217) |
| `wtm run profile add\|edit\|rm\|list` | migrated — `internal/flow/run/profile` (LUC-217) |
| `wtm run init` | migrated — `internal/flow/run/initrun`. Its questions are **not** a `flow.Session`: the services wizard edits structured rows (ports, runners, scopes, namespaces, routes, commands, profiles) that no `StepKind` renders, so it is a seam of its own, `initrun.Wizard`, answered on the CLI by `internal/tui/inittui` and, unattended, by `rules.AutoServicesAnswers`. The one standalone question left, linking the `.env` keys, goes through `Prompter.Confirm`. Its writes (`runconfig.Save`, `compose.PatchAll`, `envsvc.WritePortKeys`, `envsvc.AddEnvTargets`) are in archlint's `mutations` table. Golden files in `internal/commands/run/testdata/initgolden` pin its output |
| `wtm run open`, `wtm run url` | migrated — `internal/flow/run/open` and `internal/flow/run/url`, over `target.URLStep` and the address reader in `internal/flow/run/urls` (LUC-217) |
| CLI wizard surface | `internal/tui/flowui` |
| Unattended surface | `flow.Unattended` (in `internal/flow`) |
| Dashboard surface | `internal/tui/dashboard` (`prompter.go`, `presenter.go`, `ops.go`) |
| Test doubles | `internal/testutil/flowtest` |
| `extract` | **not migrated** — still driven by `internal/commands/wt` plus its wizard package (`internal/tui/extract`). The model was validated on paper against it; that is not the same as delivered. Tracked as LUC-182. |
| `checkout`, `relocate`, `env` | **not migrated** either, which nothing said until `archlint`'s `mutation` rule counted them: all three call their service straight from `internal/commands/`, so no second surface can run them. Listed in `.archlint-migrating`, reported on every `make lint`. |
| `StepMultiSelect` | exists since `reparent`, which needed it to keep its no-argument picker. Rendered by both surfaces: `flowui`, and the dashboard's modal since its Actions menu runs the batch reparent. Since `prune`, an `Option` can also arrive pre-checked and tagged (`Selected`, `Tag`, `Tone`). `Tone` is a `domain` enum, not a `flow` one, so `components.TagVariantOf` can hold the one mapping onto the palette without the widget library learning about `flow`. |
| `StepContent.Start` and `Option.Badges` | exist since the run module's worktree step (LUC-193), which opens its cursor on the worktree you are standing in and marks each row with what it is running. Both surfaces render them; `Badges` are the trailing words of a `StepSelect` row, where `Tag` is the leading one of a `StepMultiSelect` row. |
| `StepText` pre-fill | `StepContent.Default`, since the CRUD forms of `run job` and `run profile` (LUC-217). It is content rather than a static field because what a form opens on can depend on the answers before it. |
| `StepReorder` | asks for an order rather than a selection, since a profile's job list is its start order (LUC-217). Rendered by `flowui` and by the dashboard's modal. |
| `seam.Watcher` | the run flows' extra Presenter half. A start sequence cannot be reported through `Stage`: the surface has to be drawing before the first job is asked for, so the surface calls the sequence and hands back its `Outcome`. |
## The shape of a flow
[Section titled “The shape of a flow”](#the-shape-of-a-flow)
One package per command, splitting the run from the questions it asks:
```plaintext
internal/flow/create/
create.go the run: Request, Outcome, Presenter, Params, Run, Operation
steps.go the session: the flow.Step declarations and the recap
```
The entry point is always the same shape — one struct parameter, one outcome, one error:
```go
type Params struct {
Context flow.Context // ProjectDir, StateDir, Config
Request Request // what the surface already knows
Prompter flow.Prompter // who answers the questions
Presenter Presenter // where the phases go
}
func Run(params Params) (Outcome, error)
```
`Run` is a package-level function per command (`create.Run`, `clean.Run`), not a method on a shared type. Behind it, an unexported `createFlow` / `cleanFlow` struct holds the params so the step declarations can close over them.
**Errors are returned, never presented.** `flow` has no `Presenter.Error`: on the CLI Cobra prints the error and `rules.ExitCode` sets the exit status; on the dashboard the caller puts it in the output panel. A user abort is different — it is a `Notice` followed by `Outcome{Aborted: true}` and a `nil` error, because the user cancelling is not a failure.
## The three seams
[Section titled “The three seams”](#the-three-seams)
### `flow.Prompter` — who answers
[Section titled “flow.Prompter — who answers”](#flowprompter--who-answers)
```go
type Prompter interface {
Ask(Session) (Answers, error)
Confirm(ConfirmParams) (bool, error)
Interactive() bool
}
```
* **`Ask`** runs a whole question-and-recap sequence and returns every answer, keyed by `Step.Key`. It returns `domain.ErrUserAborted` when the user backs out.
* **`Confirm`** is a standalone decision that can only exist *after* an execution — a fast-forward that failed, a removal that needs `sudo`. There is no session left to join at that point.
* **`Interactive`** reports whether a decision may be offered at all. It is read for exactly two purposes: not offering a decision nobody can answer, and feeding a pure rule that takes it as an input (`rules.DecidePush`). Any other use puts the bypass taxonomy back into the commands, which is what this layer removes.
```go
type Session struct {
ErrLabel string // what the host calls the command if a step errors
Steps []Step
Presets Answers // values the request already carries
}
```
Three implementations:
| Implementation | Where | `Ask` | `Confirm` | `Interactive()` |
| -------------------- | ----------------------------- | ---------------------------- | --------------------------------- | --------------- |
| `flowui.Prompter` | `internal/tui/flowui` | `components.RunWizard` | `components.RunStandaloneConfirm` | `true` |
| `flow.Unattended` | `internal/flow/unattended.go` | resolves with no interaction | `false, nil` | `false` |
| `dashboard.prompter` | `internal/tui/dashboard` | a modal, over a channel | a one-question modal | `true` |
`Unattended` lives in `flow/` on purpose: it is the only implementation with no surface dependency, and it carries the bypass taxonomy — which must exist once, not once per surface.
### `flow.Presenter` — where the phases go
[Section titled “flow.Presenter — where the phases go”](#flowpresenter--where-the-phases-go)
```go
type Presenter interface {
Stage(StageParams) error // one unit of work under a progress indicator
HookPhase(HookPhaseParams) error // a titled hook section + the sink hooks stream into
Notice(Notice) // concludes the run
Status(Notice) // one line inside an ongoing phase
}
```
A flow never frames, never animates and never picks a stream — it says *what phase this is*, the surface decides how it reads. `Notice` carries a `NoticeKind` (`NoticeMessage`, `NoticeWarning`, `NoticeSuccess`); `flow.AbortedNotice` is the shared "Aborted." value.
The conclusion is **typed per command**, not generic, so `flow/` never has to decide on a format:
internal/flow/create
```go
type Presenter interface {
flow.Presenter
Created(Outcome) error
}
// internal/flow/clean
type Presenter interface {
flow.Presenter
Cleaned(Outcome) error
}
```
The outcome carries data (`domain.CreateResult`, the reparent results, the path), never text. The CLI turns it into `output.FormatCreateResult` or a JSON payload; the dashboard turns it into a line in the output panel plus a refresh message.
It is both an event and a return value, and that is not indecision: a conclusion has to be *emitted during* the run for a flow whose recap must appear before a later prompt, and the caller needs the *value* for the JSON payload and the exit code.
### `Request` — what the surface already knows
[Section titled “Request — what the surface already knows”](#request--what-the-surface-already-knows)
The request is declared by the command's flow package, not by `flow/`:
internal/flow/create
```go
type Request struct {
Branch string // positional arg
From string // --from
EnvFrom string // --env-from
FastForward bool // --ff
IfNotExists bool // --if-not-exists
}
// internal/flow/clean
type Request struct {
Branch string
Force bool // the safety axis
ReparentChildren bool
BaseBranch string
AllowPrivileged bool // may this surface hand the terminal to sudo?
}
```
It holds **no `--yes` and no `--output`**. The confirmation axis is the Prompter that was installed; the output format is the surface's business. `--force` *does* belong there: it is the safety axis, a business input the service consumes (`domain.CleanParams.Force`), not a dialogue capability.
`AllowPrivileged` is the same idea applied to a surface capability: the CLI owns the terminal it prompts on, so it can hand it to `sudo`; the dashboard is holding that terminal in alt-screen, so it sets `false` and names the way out instead.
## The step model
[Section titled “The step model”](#the-step-model)
```go
type Step struct {
Kind StepKind // StepText | StepSelect | StepBranchSelect | StepRecap
Key string // identifies the answer in Answers
Label string // the step's name in the breadcrumb / summaries
Title string
Description string
Options []Option
Branches []domain.BranchCandidate // StepBranchSelect only
Pinned string
Refresh func() []domain.BranchCandidate
Validate func(value string) error
Skip func(Answers) (skip bool, reason string)
Build func(Answers) (StepContent, error) // re-derive content, synchronously
Load func(Answers) (StepContent, error) // same, but it does I/O
LoadingMessage string
Resolve func(Answers) (Answer, error) // the whole bypass taxonomy, see below
Summarize func(Answer) string
Flag string
Arg bool
}
```
`Flag` and `Arg` are the two halves of one thing: what an unattended run should have passed. A step answered by a flag names it, a step answered by a positional says so, and `requiredErr` words the refusal accordingly — naming a `--job` that a command does not have sends the reader looking for it.
**A kind that is drawn must be read back.** `flowui`'s `answerOf` and the dashboard's modal each cross the model-per-kind switch once; a kind added to one and not the other answers empty, and the flow writes that absence as if it were the answer. `TestEveryDrawableKindIsReadBack` pins it.
`StepContent` is the part that may depend on earlier answers — `Title`, `Description`, `Options`, and `Blockers`.
```go
// Blocker is one safety refusal standing in the way of the step's dangerous
// option, stated on its own so nothing is ever lifted implicitly.
type Blocker struct {
Key string
Label string
}
```
Blockers are why the dashboard can offer a per-refusal acknowledgement where the CLI prints a list: `rules.CleanBlockers` produces them, `internal/flow/clean/steps.go` attaches them to the delete step, and the dashboard renders each as a checkbox that must be ticked before the dangerous option becomes submittable. Folding them into the prose would have made that impossible.
Answers are immutable and typed — no `any` anywhere:
```go
type Answer struct {
Value string
Skipped bool
SkipReason string
Asked bool // false for a preset, a Resolve fallback, or a skip
}
func NewAnswers(values map[string]string) Answers // "" means unanswered
func (a Answers) With(key string, answer Answer) Answers // returns a copy
func (a Answers) Get(key string) (Answer, bool)
func (a Answers) Value(key string) string
func (a Answers) Answered(key string) bool // asked, and not skipped
```
`Presets` is what keeps a flag from erasing a recap line: a preset step is **not asked**, but it is still read back by the recap builder, so `wtm create feat/x --from main` shows the same three lines as the fully interactive run. `Answered` is the converse — it tells a flow whether a human actually saw a question, which is how a recap that was auto-confirmed stays distinguishable from one that was reviewed.
### `flow.Operation` — how a flow is scheduled
[Section titled “flow.Operation — how a flow is scheduled”](#flowoperation--how-a-flow-is-scheduled)
```go
type Operation struct {
Kind string // domain.OpKindCreate, domain.OpKindClean
Mode Mode // ModeBlocking | ModeBackground
TargetKey string // the answer naming the worktree this run holds
}
```
This is what a flow declares about *how it is scheduled*, for a surface that runs several at once. `Mode` says how long it holds that surface: `ModeBlocking` (`clean`, which destroys its target) keeps it until the run ends; `ModeBackground` (`create`, whose hooks can run long) gives it back and locks its target instead. `TargetKey` names the answer carrying that target — known only once the step is answered, which is why the dashboard's prompter posts an `opTargetMsg` as soon as the session returns.
The CLI ignores all of it: one run, one terminal. `internal/tui/dashboard/ops.go` is where it is enforced, once, instead of at every action site.
Two things the run flows made necessary there, both invisible while a run held one worktree (LUC-218). The answers a `run` session hands back are **paths** — the daemon's half of a job's key — where every reader of an operation (the row, the refusal, the detail) speaks **branches**: the translation happens once, on receipt of `opTargetMsg` (`rules.BranchesForPaths`), and a worktree git cannot name keeps its path rather than losing its lock. And an operation holds a **stage per worktree** (`operation.stages`, posted with the worktree the event came from): one string per operation showed the last event received on every row it held, whichever worktree it came from.
## Command flow diagrams
[Section titled “Command flow diagrams”](#command-flow-diagrams)
### `wtm create` (delivered)
[Section titled “wtm create (delivered)”](#wtm-create-delivered)
```mermaid
flowchart TD
A["create.Run"] --> B{"--from names a known branch?"}
B -- no --> ERR1["error: branch not found"]
B -- yes --> C{"branch already checked out elsewhere?"}
C -- "yes, without --if-not-exists" --> ERR2["error: worktree exists"]
C -- no --> D["Prompter.Ask(session)"]
D --> D1["branch name — StepText"]
D1 --> D2["source branch — StepBranchSelect"]
D2 --> D3["env strategy — StepSelect"]
D3 --> D4["source update — StepSelect, skipped unless behind"]
D4 --> D5["recap — StepRecap"]
D5 --> E{"aborted?"}
E -- yes --> F["Notice aborted, then Outcome aborted with a nil error"]
E -- no --> G{"source update = fast-forward?"}
G -- yes --> H["Stage: fast-forward, then Confirm on failure"]
G -- no --> I["Stage: worktree.Create with SkipHooks"]
H --> I
I --> J{"already existed?"}
J -- no --> K["HookPhase: on_create hooks"]
J -- yes --> L["Presenter.Created"]
K --> L
```
The create flow calls `worktree.Create` with `SkipHooks: true` and runs the hooks as its own phase afterwards, so the hook output does not fight the creation progress indicator for the terminal.
### `wtm clean` (delivered)
[Section titled “wtm clean (delivered)”](#wtm-clean-delivered)
```mermaid
flowchart TD
A["clean.Run"] --> B{"branch given and Prompter interactive?"}
B -- yes --> C["Stage: worktree.Check"]
C --> C1{"absent, or a parent worktree?"}
C1 -- absent --> C2["Cleaned: already absent"]
C1 -- parent --> C3["Notice warning, stop"]
C1 -- neither --> D["Prompter.Ask(session)"]
B -- no --> D
D --> D1["worktree — StepSelect, preset by the positional arg"]
D1 --> D2["reparent children — StepSelect, skipped when there are none"]
D2 --> D3["delete — StepRecap, carrying the Blockers"]
D3 --> E{"aborted?"}
E -- yes --> F["Notice aborted, then Outcome aborted with a nil error"]
E -- no --> G["stop services, HookPhase: on_clean hooks"]
G --> H["Stage: worktree.Clean"]
H --> I{"removal failed on permissions?"}
I -- "yes, and AllowPrivileged" --> J["Confirm sudo removal, then worktree.ForceClean"]
I -- no --> K["apply the reparent plan when it was answered yes"]
J --> K
K --> L["Presenter.Cleaned"]
```
`force` for the service call is `request.Force || answers.Value(KeyDelete) == "force"`: the flag lifts the refusals up front, or the user lifts them in the recap by choosing the dangerous option. Both routes converge on one value.
### `wtm sync` (delivered)
[Section titled “wtm sync (delivered)”](#wtm-sync-delivered)
```mermaid
flowchart TD
A["sync.Run"] --> L0["load: worktree.List, resolve branch args"]
L0 --> L1{"interactive and not --dry-run?"}
L1 -- yes --> L2["Stage: scan stale parents (ClassifyParents)"]
L1 -- no --> D
L2 --> D["Prompter.Ask(session)"]
D --> D1["worktrees — StepMultiSelect, preset by args/--all, Precheck for the dashboard"]
D1 --> D2["on conflict — StepSelect, skipped when the plan has no rebase step"]
D2 --> D3["fast-forward parents — StepSelect, skipped when nothing is behind"]
D3 --> D4["recap — StepRecap, Load rebuilds the plan behind a spinner"]
D4 --> E{"aborted?"}
E -- yes --> F["Notice aborted, then Outcome aborted with a nil error"]
E -- no --> G["rebuild the plan for the answered selection"]
G --> H{"plan empty and base not included?"}
H -- yes --> I["Synced: Empty outcome, nothing rebased"]
H -- no --> J{"recap was skipped (dry-run or unattended)?"}
J -- yes --> K["Presenter.Planned(plan)"]
J -- no --> M
K --> M["Stage: worktree.Sync — rebase the cascade"]
M --> N["Presenter.Rebased(result)"]
N --> O{"--dry-run, or nothing pushable?"}
O -- yes --> Q["Presenter.Synced(outcome)"]
O -- no --> P["rules.DecidePush"]
P -- PushForce --> P1["push"]
P -- PushConfirm --> P2["Confirm, then push"]
P -- PushSkip --> P3["nothing pushed"]
P1 --> Q
P2 --> Q
P3 --> Q
```
`sync` is why the conclusion is a Presenter method and not only a return value: its recap must be shown *before* the push prompt, and an unattended or `--dry-run` run never reaches the recap at all — `Planned` is what prints the plan on those two paths, reproducing the pre-migration double output path (recap vs. `FrameStart` on stderr) without a branch anywhere reading "am I unattended".
### `wtm extract` — projected, not delivered
[Section titled “wtm extract — projected, not delivered”](#wtm-extract--projected-not-delivered)
`extract` does not run on `flow/` yet; it still drives `internal/tui/extract` from `internal/commands/wt`. The model was validated on paper against it before the layer was written, and the diagram below is that validation — what the migration is expected to look like, not what runs. It is tracked by **LUC-182**, and it is the migration that removes the temporary duplication of create's step declarations (they exist twice today: as `flow.Step` for `wtm create`, and as `components.Step` in `internal/tui/newwt` for the sub-flow `extract` embeds).
```mermaid
flowchart TD
A["extract.Run — projected"] --> B["Ask: source worktree — StepSelect"]
B --> C["Ask: files — StepMultiSelect, Load from the chosen source"]
C --> D["Ask: target worktree — StepSelect, plus a create-new row"]
D --> E["Ask: the create sub-flow steps, gated on create-new"]
E --> F["Ask: move or copy — StepSelect"]
F --> G["Ask: recap — StepRecap"]
G --> H["service: conflicting files for this selection"]
H --> I{"conflicts?"}
I -- none --> J["on-conflict = abort"]
I -- "yes, --on-conflict set" --> K["use the flag value"]
I -- "yes, not interactive" --> J
I -- "yes, interactive" --> L["Confirm: resolve or abort"]
J --> M["execute the extraction"]
K --> M
L --> M
```
The on-conflict decision stays *outside* the session on purpose: the conflict list depends on the selection **and** on the state of the disk, so it can only be asked after the recap — a post-execution `Confirm`, like create's failed fast-forward.
## One flow, three surfaces
[Section titled “One flow, three surfaces”](#one-flow-three-surfaces)
The same `create.Run` call, on the three surfaces. Only the two seams change.
### CLI, interactive
[Section titled “CLI, interactive”](#cli-interactive)
```mermaid
sequenceDiagram
participant Cmd as commands/wt/create.go
participant Flow as flow/create.Run
participant P as flowui.Prompter
participant Pr as cliPresenter
participant Svc as service/worktree
Cmd->>Cmd: parse flags, LoadConfig
Cmd->>Cmd: interactive = human and TTY and not --yes, so true
Cmd->>Flow: Run with flowui.New and createPresenter
Flow->>P: Ask(session)
P->>P: components.RunWizard — breadcrumb, Esc steps back
P-->>Flow: Answers, Asked true
Flow->>Pr: Stage "Creating ..."
Pr->>Svc: worktree.Create
Svc-->>Pr: CreateResult
Flow->>Pr: HookPhase on_create
Pr->>Svc: RunCreateHooks with Output = cmd.ErrOrStderr
Flow->>Pr: Created(outcome)
Pr->>Pr: output.Frame and FormatCreateResult
```
### CLI, unattended — `--yes`, no TTY, or `--output json`
[Section titled “CLI, unattended — --yes, no TTY, or --output json”](#cli-unattended----yes-no-tty-or---output-json)
```mermaid
sequenceDiagram
participant Cmd as commands/wt/create.go
participant Flow as flow/create.Run
participant P as flow.Unattended
participant Pr as cliPresenter
participant Svc as service/worktree
Cmd->>Cmd: interactive = human and TTY and not --yes, so false
Cmd->>Flow: Run with flow.Unattended and createPresenter
Flow->>P: Ask(session)
loop each step
P->>P: preset, else Skip, else Resolve, else refuse naming the flag
end
P-->>Flow: Answers, Asked false
Flow->>Pr: Stage "Creating ..."
Pr->>Svc: worktree.Create
Note over Pr: same presenter, no animation, JSON payload on stdout
Flow->>Pr: Created(outcome)
```
Nothing about the flow changes — no branch anywhere in `create.Run` reads "am I unattended". The only decision the command makes is *which Prompter to install*.
### Dashboard
[Section titled “Dashboard”](#dashboard)
```mermaid
sequenceDiagram
participant UI as dashboard Model, UI goroutine
participant G as flow goroutine
participant Flow as flow/create.Run
participant P as dashboard.prompter
participant Pr as dashboard.createPresenter
UI->>UI: busyReason, then beginOp with create.Operation
UI->>G: a tea.Cmd starts the run
G->>Flow: create.Run with the dashboard prompter and presenter
Flow->>P: Ask(session)
P->>UI: promptMsg carrying the session and a reply channel
UI->>UI: open the modal, render one step at a time
UI-->>P: promptReply with the answers
P->>UI: opTargetMsg — the run now holds this branch
P-->>Flow: Answers
Flow->>Pr: Stage, HookPhase, Created
Pr->>UI: one OutputLineMsg per line, then createdMsg
G-->>UI: opDoneMsg
```
Two things make this safe rather than merely possible: the flow runs on **its own goroutine**, and every single thing it produces reaches the model as a `tea.Msg` on the dashboard's channel. The prompter blocks on a reply channel — from the flow's perspective `Ask` is an ordinary blocking call — while the UI goroutine keeps rendering.
## Unattended resolution and the two axes
[Section titled “Unattended resolution and the two axes”](#unattended-resolution-and-the-two-axes)
`flow.Unattended.Ask` is the entire bypass taxonomy, in one loop:
```go
func (Unattended) Ask(session Session) (Answers, error) {
answers := session.Presets
for _, step := range session.Steps {
if _, known := answers.Get(step.Key); known {
continue // the flag or the positional arg already answered it
}
if step.Skip != nil {
if skip, reason := step.Skip(answers); skip {
answers = answers.With(step.Key, Answer{Skipped: true, SkipReason: reason})
continue
}
}
if step.Resolve == nil {
return Answers{}, requiredErr(step) // interactive-only: refuse, naming the flag
}
answer, err := step.Resolve(answers)
if err != nil {
return Answers{}, err
}
answers = answers.With(step.Key, answer)
}
return answers, nil
}
```
`Resolve` is where the three documented cases live, declared **on the step**, next to the question they answer:
| Case | How the step declares it | Example |
| ----------------------------------------------- | ---------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1. Decision or confirmation with a safe default | `Resolve` returns an `Answer` | create's env step returns `""` (the config default); create's source-update step returns `ff` only when `--ff` was passed, else `keep`; clean's reparent step returns `orphan`; every recap returns its confirm value |
| 2. Required selection with no safe default | `Resolve` returns an **error naming the flag** | create's source step refuses to guess the parent of a pre-existing branch and says to pass `--from`; clean's worktree step returns `domain.ErrCleanBranchRequired` |
| 3. Interactive-only | **no `Resolve` at all** | `Unattended` refuses with `requiredErr(step)`, built from `step.Label` and `step.Flag`. It never falls back to a picker — it cannot even open one |
The default a `Resolve` returns is never destructive. That is a rule, not an observation: `clean --yes` leaves children orphaned unless `--reparent-children`, `sync --yes` does not push.
**`--force` never travels this path.** It is not an answer, it is a field of the `Request` — the safety axis. Two consequences worth keeping in mind:
* `--force` alone does **not** imply `--yes`. The session still runs and still asks to confirm; the refusals are simply already lifted.
* `--yes` alone does **not** lift a refusal. `clean --yes` on a dirty worktree fails, naming `--force`: see `resolveDelete` in `internal/flow/clean/steps.go`, which runs the safety check *while* answering the step.
The command's only job on this axis is choosing the Prompter, in one line:
```go
interactive := rules.IsHumanFormat(format) && !yes && term.IsTerminal(int(os.Stdin.Fd()))
// ...
Prompter: flowPrompter(flowPrompterParams{Interactive: interactive}),
```
## Hook output, from the service writer to the dashboard panel
[Section titled “Hook output, from the service writer to the dashboard panel”](#hook-output-from-the-service-writer-to-the-dashboard-panel)
`hooks.RunHooks` sets `cmd.Stdout = output` on each hook, so hook stdout is already incremental at the source. What the flow layer adds is a way for a surface to receive it as *events* rather than as a stream.
```mermaid
flowchart LR
F["flow: HookPhase, Run(sink) calls RunCreateHooks with Output = sink"] --> P["Presenter.HookPhase"]
P -->|CLI| S1["sink = cmd.ErrOrStderr — bytes straight through"]
P -->|dashboard| S2["sink = flow.LineWriter"]
S1 --> Term["the terminal, output unchanged"]
S2 --> M["one OutputLineMsg per line"]
M --> Panel["the dashboard's bottom output panel"]
```
The flow never writes: it asks the Presenter for a sink and hands that sink to the service. `flow.LineWriter` is an `io.Writer` that buffers the current fragment and emits on each `\n`, with a `Flush` for the trailing partial line. It lives in `flow/` because it depends on nothing but the stdlib.
**The concurrency point.** `RunHooks` is synchronous and writes from the calling goroutine. So the dashboard runs the flow in a goroutine, and `LineWriter.Emit` posts a `tea.Msg` on the dashboard's channel — it never mutates a model. This is the whole reason `presenter.HookPhase` reads the way it does:
```go
func (p presenter) HookPhase(params flow.HookPhaseParams) error {
p.line(params.Title)
sink := &flow.LineWriter{Emit: p.line} // p.line sends a tea.Msg
err := params.Run(sink)
sink.Flush()
return err
}
```
`LineWriter` is not concurrency-safe, and does not need to be: one hook run, one writer, one goroutine.
What stays buffered on purpose is each hook's **stderr** — it is only printed when the hook fails, under the `✗` line. Streaming it would change how CLI output interleaves.
## Testing a flow
[Section titled “Testing a flow”](#testing-a-flow)
A flow is tested without a terminal, by handing it the two doubles in `internal/testutil/flowtest`:
```go
prompter := &flowtest.ScriptedPrompter{Answers: map[string]string{
create.KeyBranch: "feat/x",
create.KeySource: "main",
}}
recorder := &flowtest.Recorder{}
```
* **`ScriptedPrompter`** walks the session the way a real host does — honoring `Skip`, `Build` and `Load` — and answers each step from the script. It records `Asked` (with `AskedKeys()` for a one-line assertion) and the `StepContent` each step produced, so a test can assert on *what the user would have seen*, not only on the outcome. A step with nothing scripted is an error, so a new question cannot slip into a flow unnoticed.
* **`Recorder`** implements `flow.Presenter`, collecting `Stages`, `Hooks`, `Notices` and `Statuses`. It runs `Work()` and `Run(sink)` for real, so the service still gets called.
The typed conclusion (`Created`, `Cleaned`) is not part of `Recorder` — a test that needs it embeds the recorder and adds the one method:
```go
type recorder struct {
*flowtest.Recorder
outcome create.Outcome
}
func (r *recorder) Created(o create.Outcome) error { r.outcome = o; return nil }
```
For the unattended path, `flow.Unattended{}` **is** the test double: passing it directly is how the resolution taxonomy is tested (`internal/flow/unattended_test.go`).
**Characterization tests.** Before `create` and `clean` were migrated, their observable CLI behavior was pinned by tests written against the *old* code: `internal/tui/newwt/create_flow_test.go`, `internal/commands/wt/create_wizard_test.go`, `create_noninteractive_test.go` and `integration_test.go` (the `--yes` / `--force` axes, the JSON reparent default, idempotence on an absent worktree). `prune` got the same treatment in `internal/commands/wt/prune_test.go`, which needed two new fixtures to reach its core at all: `internal/testutil/ghtest` scripts the GitHub CLI through `PATH`, and `gittest.AddOrigin` gives branches a real upstream. They exist to be run unchanged after the refactor. Keep them that way: they are the only thing that proves a flow that moved packages still reads the same to a user. When you migrate a command, write its characterization tests first, and do not "fix" one to make a refactor pass.
## Two decisions worth not re-opening
[Section titled “Two decisions worth not re-opening”](#two-decisions-worth-not-re-opening)
### A non-mutating mode is a business input (`prune --dry-run`)
[Section titled “A non-mutating mode is a business input (prune --dry-run)”](#a-non-mutating-mode-is-a-business-input-prune---dry-run)
`--dry-run` looks like an output mode, and the first instinct is to keep it out of the `Request` on the grounds that a request carries business inputs, not presentation. That reading is wrong: `--dry-run` does not change *how the run reads*, it changes *what the run does* — nothing. It belongs with `--force` on the input side, the way `terraform plan` sits beside `apply`.
So `prune.Request.DryRun` exists, and `Run` returns an `Outcome` carrying the plan before it asks a single question and before it touches anything. The alternative — a second exported `Plan()` the runner calls instead of `Run()` — would have kept a plan computation path in `commands/` and forced the `gh` advisory to be emitted from two places.
`--force` is OR'd with the recap's own answer (`request.Force || answer == confirmForce`), which is a deliberate change from the pre-`flow` `prune`: it read force from the picker's answer alone, so `wtm prune --force` on a TTY followed by a plain "Yes, prune" dropped the unsafe worktrees again. The two-axis model says otherwise — `--force` lifts the refusals and is not re-asked — and `clean` already behaved this way. Pinned by `TestForceSurvivesAPlainConfirmation`.
One trap comes with it, and it is why `rules.PruneClassifyForce` takes `DryRun` as an input rather than being a `||` in the flow. A surface may perfectly well install an interactive Prompter *and* set `DryRun`. Classifying with force because "someone could uncheck" would then make a preview list worktrees a real run would have skipped. The rule states the three-term condition once, and tests it.
### The three reparent service functions stay three
[Section titled “The three reparent service functions stay three”](#the-three-reparent-service-functions-stay-three)
`worktree.ReparentBatch` (for `wtm reparent`), `worktree.ApplyReparentChildren` (for `clean`) and `worktree.ApplyReparents` (for `prune`) all rewrite a worktree's recorded parent. `prune` being the last of the three callers to migrate, the question of folding them into one was raised deliberately here — and answered: **no.**
* There is **no duplicated logic to remove.** All three funnel into `setSourceBranch`, which is already the single chokepoint for the write.
* Their contracts genuinely differ. Only `ReparentBatch` validates acyclicity, because only it moves worktrees onto a parent the user chose. The other two reattach children to a grandparent, which cannot close a cycle by construction — giving them the check would be dead validation.
* Merging them yields one function with behaviour flags (`Validate bool`, `Grandparent bool`) that is harder to read than the three signatures it replaces, and hides which caller is allowed to skip which check.
What is broad is the *exported surface*, not the logic. Three names for one idea is a fair price for three honest contracts. Do not consolidate them without a new reason.
## sync — what this migration settled
[Section titled “sync — what this migration settled”](#sync--what-this-migration-settled)
### `--keep-conflict` from the dashboard: offered, and the exit named
[Section titled “--keep-conflict from the dashboard: offered, and the exit named”](#--keep-conflict-from-the-dashboard-offered-and-the-exit-named)
The dashboard poses the on-conflict step exactly as the CLI does: the decision stays the user's, and the dashboard hides none of the options the CLI exposes. In return, when a conflict is kept, the output panel names — per branch — the worktree path and the `git rebase --continue` / `--abort` to run there (`domain.SyncKeepConflictHintFmt`). Same gesture as `DashboardPrivilegedHintFmt` for a privileged removal: the surface cannot finish the job, so it says where to finish it.
Not offering the option was rejected — it amputates something the CLI exposes and sends the user back to a terminal to re-run the whole cascade. Exiting the dashboard on a conflict was rejected too: it needs a shell integration the dashboard does not have, and throws the user out of a surface they just opened, for the one outcome where reading the hint matters most.
Accepted limit: `ModeBlocking` protects a worktree for the duration of the run only. Nothing stops another operation from touching one left mid-rebase afterwards — the `⟳ rebasing` badge, not a lock, is what surfaces it.
### What the dashboard pre-checks
[Section titled “What the dashboard pre-checks”](#what-the-dashboard-pre-checks)
`Branches`/`All` *fix* the selection, so the step becomes a preset the recap still reads back. `Precheck` only says which boxes arrive **checked** when the step is asked. The CLI never passes it — its picker opens empty, as `syncpicker` did — and only the dashboard entries populate it.
`Sync this worktree` pre-checks the row's **ancestry**, base included (`rules.SyncAncestry`), not its subtree. A worktree is rebased onto its parent, so replaying one whose parent nobody refreshed lands it on a stale ref — the very problem the `Parent branches` question exists to rescue after the fact. Pre-checking the chain removes it instead of asking about it. Descendants are left out: dragging them in makes the same entry mean one worktree from a leaf and four from a root, an asymmetry no label lets you predict.
The run module's batch entries (`Start worktrees`, `Stop worktrees`, `Watch worktree logs`) split it the same way: a **start** is about where you are, so it passes no precheck at all — `target.WorktreesStep` already opens with the current worktree ticked — while a **stop** and a **view** are about what is standing, and pass the worktrees the board holds something up in (`rules.RunningWorktreeDirs`). `Stop worktrees` is also the one place `run down` asks anything: from a row there is nothing to ask, stopping everything there being the safe default, and from the global menu there is no row to answer for it.
`Sync worktrees` (`⋯ Actions`) pre-checks everything except `dirty` and `rebasing` worktrees, which stay listed and tagged, one keystroke from being included — a deliberate divergence from `--all`, which excludes nothing, and the same explicit-vs-batch logic `prune` established. It is named `Sync worktrees` rather than `Sync all worktrees` because it opens a selection; a label promising "all" reads as a sweep with no way out. `--dry-run` gets no entry, as in `prune`: the recap **is** the plan and closing the modal rebases nothing, so every dashboard sync gesture is already a preview until it is confirmed.
### The no-terminal refusal
[Section titled “The no-terminal refusal”](#the-no-terminal-refusal)
Human output, no TTY, neither `--yes` nor `--dry-run` → refuse, naming `--yes` (`domain.SyncNeedsTerminal`), aligned with `prune`. It is one of the migration's **two** CLI-observable behavior changes (the other is the plan header, below), and it closes a real gap rather than tidying one: before it, `wtm sync feat-a | cat` launched a TUI confirm on a non-TTY and the failure *was* the safety net — no confirm, nothing ran. `flow.Unattended` has no such accident to fall back on, so without the guard that path would have **mutated** where it used to abort.
The second is the cascade preview's header, now the constant `domain.SyncPlanHeader` ("Sync plan"): it no longer gains and loses a `(base: x)` suffix depending on whether the cascade happens to touch the base. A header that comes and goes reads as two different sections to whoever meets it twice, and the plan's own lines already name every branch involved. It reaches the user through `Planned` — on stderr, where the plan has always been written — on every run that saw no recap: `--dry-run`, `--yes`, or no TTY.
One nuance keeps the condition from being a copy of `prune`'s: `sync`'s `interactive` deliberately omits `!dryRun`, because `--dry-run` on a TTY still needs the picker to choose *what* to preview. The refusal clause itself is identical.
Two picker renderings changed with no test to catch them, the frozen characterization tests all running without a TTY: `dirty`/`rebasing` worktrees carry a `flow.Option{Tag, Tone}` badge instead of a `" (dirty)"` label suffix, so both surfaces read one step vocabulary; and the on-conflict question is skipped when the plan holds no rebase step, instead of asking about a situation that cannot occur.
## Known gaps
[Section titled “Known gaps”](#known-gaps)
Deliberately open, tracked, and not to be fixed opportunistically:
| Ticket | Gap |
| ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| LUC-179 | `clean --force` alone without a TTY skips the safety check — pre-existing, made visible by this design |
| LUC-180 | `flow.Context` duplicates `shared.ConfigResult` (the latter imports cobra, so it cannot be reused as is) |
| LUC-182 | `extract` is not migrated; create's step declaration therefore exists twice |
| — | `internal/flow/run/job` and `internal/flow/run/profile` are the same four entry points over two unrelated config types, so their `Add`/`Edit`/`Remove`/`List` shells read as clones. Sharing them would mean generics over `JobConfig`/`ProfileConfig` for no reader's benefit |
| LUC-183 | `flow.Step` carries kind-specific fields (`Branches`, `Pinned`, `Refresh`, `Validate`/`ValidateSet`) on every kind. It also means a `StepBranchSelect` reads its candidates from `Step.Branches`, before any answer exists, so it cannot narrow them from an earlier step — `reparent` narrows from what its request already names instead |
| LUC-184 | Locked worktrees are only taken into account by `relocate`, so "locked" is not among clean's blockers |
| LUC-188 | `busyReason("")` only sees blocking runs, so a `ModeBackground` run holding a worktree does not stop a `ModeBlocking` run with no target (the batch reparent, `prune`) from acting on it |
| — | When every prune match is skipped, the run reports an empty result instead of the skips that explain it. Pinned by `TestPruneAllUnsafeReportsNothing` so a refactor cannot change it silently; fixing it is its own decision |
# The output layer
How a `wtm` command speaks. `CLAUDE.md` carries the rules in short form; this is the reasoning behind them, and the reference to read before adding a command or changing what one prints.
## The one question
[Section titled “The one question”](#the-one-question)
A block earns its place when it changes what the reader does next. Not when it is true, not when it was expensive to compute, not when it is interesting — when it changes what they do. Everything below follows from that.
The question has a corollary that is easy to get backwards, so it is worth stating on its own: **success contracts, an anomaly expands.** A pass that did exactly what was asked is a count. A refusal, a conflict, a link matching nothing is named one by one, because the reader can only fix the one they can see. Giving the nominal path as much room as the actionable one is what makes a CLI read as noise — and it is the shape most reports drift into, because listing what happened is easier than deciding what matters.
Two more consequences, in the order they bite:
**Detail belongs to the command whose subject it is.** Ports are the subject of `wtm env` and `wtm run init`; in `create` and `extract` they are a side effect, so they collapse to a count on the recap's env line. A reader who wants the values runs the command that is about them, or opens the file the run just wrote. The file is the record; the command says how many and where.
**A successful run has a fixed shape.** What makes output feel bloated is not its size but its variance: a conclusion whose height depends on what happened can never be recognised at a glance, so it has to be read. `wtm create` is the same six lines whether it settled three ports or thirty.
## Two streams, three registers
[Section titled “Two streams, three registers”](#two-streams-three-registers)
stdout is the result — what a script would read. stderr is everything about getting there.
Three registers, and only the third may grow with what happened:
| Register | Where | Lives for | Examples |
| ------------- | ------ | -------------------- | ----------------------------------------------- |
| **Result** | stdout | the scrollback | the framed conclusion, a table, a state readout |
| **Progress** | stderr | until it is replaced | spinners, a hook's tail, a job's raw output |
| **Attention** | stderr | the scrollback | warnings, refusals, anomalies, callouts |
Progress is erased, so it is never barred and never framed — the bar marks what stays. Attention is the only register allowed one line per item.
The corollary is that **everything a run says while it is still running, and keeps, is one block**. A migrated command's status lines and hook phases used to write straight to stderr, which left them the only human output outside the bar; `shared.OpenBlock` puts them inside one, and the conclusion is a second block on stdout — which is what "exactly once" already allows.
**A surface remembers where its last block left the cursor**, and that is why the bookkeeping lives in `output` rather than in the presenter. The blank closing a block and the blank opening the next are the same line on screen, so a caller deciding whether to open one cannot answer from what it did itself: the frame beside it is written by code that never sees it — `run up`'s own frame around a job's output is the case that made this necessary. `FrameStart` therefore writes no blank on a surface already at a boundary, writes the separator on one whose block is still open, and `BlockOpen` is what `OpenBlock` and `syncPresenter.section` both read. stdout and stderr are **one** surface when both are the same terminal: the reader sees one column of blocks, whichever stream wrote them.
The consequence for a flow: **never report from inside a `Stage`**. A spinner owns the stream while it runs, so a line written under it is repainted over — and the block it opened is then marked open with nothing on screen to show for it. Collect what happened and report it after the stage returns (`internal/flow/run/up/up.go`, `clearOthers`).
## The frame
[Section titled “The frame”](#the-frame)
Every human conclusion is framed **exactly once**, with `output.Frame` or — for a command writing across two streams — the `FrameStart`/`FrameEnd` pair. The frame owns two things at once: the single blank line above and below the block, and the accent bar down its left edge.
```go
output.Frame(cmd.OutOrStdout(), func(w io.Writer) {
output.Success(w, "Created worktree feat/x")
})
```
The body writes to the writer it is **handed**, never to the one `Frame` was given. That is what puts the bar on every line, in the one place the padding is already applied. A formatter therefore emits a raw body: no leading blank, no trailing blank, `output.Blank` only as a genuine separator between sections inside the block.
A streaming pair wraps its own body writer: `output.Barred(w)`. When a command writes across two streams — `sync`'s plan on stderr, its recap on stdout — there is one rule rather than two mechanisms: **every section opens with exactly one blank line on the stream it is about to write to**, the first of them being the frame's leading blank, and `FrameEnd` closes. Same call, same output, one mechanism.
JSON and machine output are never framed and therefore never barred. They emit flush.
### The bar
[Section titled “The bar”](#the-bar)
`┃`, in column zero — left of everything else the CLI prints, which is what makes it a marker rather than one more indent. In a terminal running `git`, `pnpm` and `docker`, it says *this block is wtm speaking*.
It goes on a **terminal only** (`output.IsTerminal`). A pipe, a CI log or a redirection gets the bare text, so `wtm create | tee log` stays clean and a grep over that log never has to know about the bar.
## The four levels, and what each is for
[Section titled “The four levels, and what each is for”](#the-four-levels-and-what-each-is-for)
A visual system holds by its contrasts, not by its repetitions. If everything is marked, nothing is.
| Level | For | Where |
| ---------------------------------------------- | ------------------------- | ---------------------------------------------- |
| **A flat line** | an act you just performed | `create`, `clean`, `checkout`, `run job add` |
| **A table** | an inventory you consult | `list`, `tree`, `run ps`, `run list` |
| **A pill-titled block** (`styles.RenderRecap`) | a state you come back to | `init`, `run up`, `run down` |
| **A callout** (`output.Callout`, bordered) | something still to act on | port isolation, proxy hints, withheld bindings |
The pill is the contrast element and stays rare. A one-line conclusion in a box is five lines of chrome around one line of content — that is the reductio, and it is why the box is not the standard.
## The two shapes of a conclusion
[Section titled “The two shapes of a conclusion”](#the-two-shapes-of-a-conclusion)
**Form A — the act.** One subject:
```plaintext
┃ ✓ Created worktree feat/x
┃
┃ from main
┃ env main · 4 ports settled (offset +10)
┃ path .worktrees/feat-x
┃
┃ → wtm go feat/x
```
A `✓` headline, nought to three aligned fields, at most one next step. Budget: 8 lines.
**Form B — the readout.** Several objects:
```plaintext
┃ ✓ 3 pruned · 1 skipped
┃ feat/a, feat/b, feat/c
┃
┃ ! docs/api skipped — open PR #42
```
`output.Tally` counts, zero counts dropped; then **one line per exception only**, never per success. Budget: 6 lines plus the exceptions.
One nuance that is not a matter of taste: a **destructive** run names what it destroyed — knowing what is gone is actionable — but on one line, because the picker and the recap have already shown that list twice. A non-destructive run counts.
### A run's addresses
[Section titled “A run's addresses”](#a-runs-addresses)
A run is the one conclusion that lists addresses, and it does it once: each job line carries a single fragment (`rules.ReachSummary` — the URL, `:5432`, `3 urls`, `6 ports`), and the full list is the **Where to reach it** block (`rules.ReachBlock`) the run ends on, the run view shows behind `a`, and its recap keeps. A port list on a job line is how `docker-compose` came to take 160 columns; see [run-addressing.md](/0-28/dev/run-addressing/#where-to-reach-it--one-model-for-every-surface).
## The glyph vocabulary
[Section titled “The glyph vocabulary”](#the-glyph-vocabulary)
Exhaustive. One glyph per line, at its head; never two vocabularies in one block.
| Glyph | Means | Helper |
| ----- | ---------------------------------- | ------------------ |
| `✓` | changed state, and it worked | `output.Success` |
| `=` | was already in the desired state | `output.Unchanged` |
| `~` | an existing thing was replaced | `output.Update` |
| `!` | needs attention; the run continues | `output.Warning` |
| `✗` | failed | `output.Error` |
| `›` | in progress — ephemeral only | `output.Loading` |
| `→` | what to do next | `output.NextStep` |
The runes live in `domain` (`GlyphSuccess`, `GlyphAttention`, …), not as literals in `output/`, so a seventh cannot be introduced by typing one.
### Three rules that make the vocabulary hold
[Section titled “Three rules that make the vocabulary hold”](#three-rules-that-make-the-vocabulary-hold)
The table above was already written, and the surface diverged anyway — because it fixes the rune and says nothing about the rest of the row. These are the parts that were missing.
**1. The glyph carries the only colour on its line.** The message beside it stays in the terminal's own foreground. Green, yellow and red are a margin of signals down the left of a block, not a property of the text: a reader scans the margin and reads the words. Two registers are the exception, and for one reason — `=` and `›` mute their line **whole**, because there the line itself is the non-event.
Which kills `output.Danger`, and with it the third failure register. `!` is something left to do, `✗` is a failure; a refusal and a crash are the same register, and which of the two it was belongs in the sentence. The old boundary was decided file by file — `sync` and `relocate` called a blockage `Danger`, `fast-forward` called the same idea `Warning`.
**2. Every glyph is one column.** `!` used to render as a filled chip carrying its own padding, so an attention line sat two columns wider — and read louder — than the failure line under it. `internal/output/env.go` had already left the vocabulary over this, rendering a bare `!` because the badge "made the rows wander a column apart". Badges belong to the TUI, where a chip is a widget; a line of CLI output is text. One column is also a property of the **font**, not only of the rune: a glyph the terminal's font lacks is drawn from a fallback face, often wider, and overflows onto the space after it. `↻` did exactly that under JetBrains Mono (Ghostty's default), which is why the update glyph is `~`. `make lint` holds this through `archlint`'s `fontcover` rule: a non-letter rune in any string of `internal/` must belong to `fontSafe`, measured as present in thirteen common monospace fonts (box drawing and block elements are exempt, terminals draw those themselves). The runes that predated the rule — `▸`, `⚠`, `●`… — are listed in `fontLegacy`, report as migrating, and that list may only shrink.
**3. `Muted` has exactly two jobs, and detail is not one of them.**
* **Chrome**: what is never content — a field's label, a table's header row, a tree's connectors, the note glossing a `NextStep` command.
* **A non-event, whole**: the `=` and `›` lines above.
Secondary detail is expressed by **indentation, not by colour**. A branch list under a count, the lines of a failure's captured output, an address under a job: they are content, they sit one indent in, and they keep the foreground. Muting them was the third job, and it is the one that made the same class of information read at three different densities depending on the command.
These three are checked by `make lint` (`tools/archlint`, rules `glyph`, `tuistyle`, `mutedline`) over `output/`, `styles/` and `tui/` — the layers that put glyphs on a screen. What a linter cannot check it cannot hold, and the first version of this document proved that a table alone does not survive sixty commands.
### What follows from the three rules
[Section titled “What follows from the three rules”](#what-follows-from-the-three-rules)
**"Nothing to do" is `=`, everywhere** — and "everywhere" includes the places that are not a conclusion. An **empty inventory** is a non-event: `output.UnchangedLine` is `Unchanged` for a formatter that returns a body, so an empty table takes the same glyph as a command that found nothing to do. So does **backing out**: an abort changed nothing, and it is `=` with one wording (`domain.AbortedMessage`) rather than a bare sentence in four.
A **state readout** may not hide a non-event as a field value either. `not running` and `not installed` are the `=` register; a `Section` line is where the detail goes, under a conclusion, never instead of one.
**A conclusion is not optional.** Every human command ends on exactly one, in one of the four shapes of the section above. A readout with no line over it makes the reader infer the outcome from a field.
**A hint is `output.NextStep`, everywhere**: one arrow, one bold command, an optional muted note. A reader learns once where to look for what to do next. Prose telling someone what to run — backticked in a `Message`, muted in a box, inline after a `›` — is the same information in a place nobody looks twice.
**The four block helpers, arbitrated.** They overlapped for as long as nothing said which was which, so two sibling readouts ended up aligned two different ways and two sibling previews titled two different ways.
| Helper | Shape | For |
| -------------- | ---------------------------------------------------- | --------------------------------------------------------------------- |
| `SectionTitle` | the bold title alone | a caller that draws its own body — a table, a stream, glyphed lines |
| `Announce` | title + `label value` rows, labels aligned and muted | anything a reader looks *up*: a plan before a picker, a state readout |
| `Section` | title + indented free lines | a script, a file's contents, a listing |
| `Callout` | a bordered box | **only** something the reader still has to act on |
`flow.Notice` carries that last distinction across the seam: `NoticeNote` is what the reader has nothing to do about — a property of the machine, or of the file that was just written — and takes `Section`; a warning carrying lines is what wtm declined to do, and keeps the border. The port pass is both at once: the links it left alone are bordered, `Addresses carry the proxy's port` is not — and that one is said by `wtm env` and `wtm run addressing`, whose subject it is, never by a creation (`rules.EnvPortNoticesOnCreate`).
The alignment belongs to `Announce`, never to the wording: a format string spelling `"State %s"` hand-aligns one block against nothing, and its sibling three files away picks a different column.
**A `--dry-run` answers on stdout.** A preview is what the caller asked for, so it is the result and not a diagnostic. A plan shown *before* a real run — `sync`'s — is a preamble and stays on stderr.
**"Exactly once" counts uninterrupted blocks, not frames.** A command frames each block of human output once; a prompt between two blocks makes two, because there are two blocks. So does a split across streams — `run down`'s failures on stderr and its recap on stdout. What the rule forbids is a second frame around the same block, or a helper emitting its own padding inside one.
**A diff is not a register.** `wtm env` prints `+` / `!` / `−` per key, and that is deliberate: those runes describe a *change to a line of a file*, not the state of a run, and they read as a column down the left of a file block rather than as the head of a conclusion. It is the one vocabulary outside the table, it is confined to `output/env.go`, and adding a second one is a decision to argue for here first.
**The status palette names states, never identities.** `run logs` used to cycle green and yellow across job prefixes, so in the one command whose body is job output, yellow meant "job 3". A label saying where a line came from is chrome.
**`output.Message` — the bare line, carrying no status — is not for a conclusion.** It is the most-called helper in the tree, and that is the symptom it names: when nothing in the vocabulary fits, people fall back to a line that says nothing. A conclusion line carries a glyph or is an aligned field.
## `--quiet`
[Section titled “--quiet”](#--quiet)
The output axis, and nothing else. It replaces the command's writers with `io.Discard`, so every framed conclusion, notice and progress line goes nowhere — while the error and the exit code still arrive, because `Execute` prints those to `os.Stderr` rather than through the command.
It never touches a machine contract: `--output json` still emits its document, and a command whose stdout **is** the answer declares `domain.AnnotationMachineOutput` and is left alone. Asking for less noise is not asking for less answer.
The corollary is easy to lose. `domain.ErrAborted` means *the command already printed its own report*, and that stops being true the moment the report went to `io.Discard`: a run that exits non-zero having written nothing to either stream cannot be told from one that hung. So `--quiet` records that it silenced the writers, `Execute` prints the error even for `ErrAborted` when it did, and a site returning that sentinel over a refusal wraps its cause (`fmt.Errorf("%w: %s", domain.ErrAborted, …)`) so there is something to print. The same rule reaches the hook runner: with no reporter installed nobody has drawn the hook's result line, so `service/hooks` names the failing command in the error rather than leaving it anonymous.
It is orthogonal to `--yes`, like the two bypass axes: `--quiet` still asks, `--yes` still reports, and a script that wants neither passes both.
## The machine contract
[Section titled “The machine contract”](#the-machine-contract)
`--output json` is read by programs, so its shape is decided once and never follows what happened. Four rules hold for the `run` module, and a new document follows them rather than its neighbour:
* **One shape per command.** A command that acts on worktrees answers with an array of per-worktree documents even for one worktree (`run up`, `run down`, `run stop`, `run logs`); a single-subject command answers with one object (`run start`, `run job|profile add|edit|rm`). A shape that changed with the arity made every caller branch on how many worktrees it had named.
* **A worktree is `branch` + `path`**, both, always — never `worktree` or `work_dir`. `domain.WorktreeRef` is the type when nothing else rides along. A job object is keyed `name`; anything pointing at a job from another object calls it `job`.
* **`status` never claims an act that did not happen.** A stop that found nothing up is `not_running`, never `stopped`; a start that found the service already up is `already_running`, never `started`; a shared job let go of is `released`.
* **Exit codes are part of the document.** `rules.ExitCode` maps the sentinels: `2` for a command line cobra refused (`cmd/usage.go` wraps its flag and argument errors, and an unknown `--output`, in `domain.ErrUsage`), `14` for a job or profile run.toml does not declare (`ErrJobNotFound`, `ErrProfileNotFound`), checked by `target.RequireDeclared` before a flow asks anything or wakes the daemon.
A document that is a protocol elsewhere is not reused for output: `domain.JobInfo` is what the daemon speaks, so `run ps` writes `domain.RunningJob`, and renaming a key there never needs a daemon restart. `run list`, `run export` and `run import` are the exception to the naming rule on purpose — they are run.toml as JSON, and keep its keys (`job`, `profile`, `env_port`).
## Showing without keeping
[Section titled “Showing without keeping”](#showing-without-keeping)
A hook that runs for forty seconds has to be visible while it runs — silence reads as a hang — and must not survive in a scrollback nobody rereads. `output.HookView` is the shape: a bounded tail redrawn in place, erased and replaced by one `✓ (12.4s)` line, and the tail kept on screen when the hook failed.
It applies to a terminal this process may repaint. A pipe, a CI log or `--output json` gets the raw stream, unconditionally.
Both paths go through one function, `commands/shared.DrawHookPhase`, and it is one function on purpose: the two callers — the migrated commands through `CLIPresenter`, `extract` and `checkout` through `RunCreateHooksPhase` — drifted apart once, and a hook has to read the same whichever command ran it. It owns the log rather than the view, opening `/hooks/-.log` and teeing the raw stream into it on **every** path: the run whose output the reader could not watch is exactly the one whose record has to survive. And it always hands the sink a real writer — the command's own — because a sink left nil falls back to `os.Stderr` in the runner, which is how a hook finds its way onto a terminal that asked for `--quiet`.
A hook's own bytes are never barred, for the same reason progress is not: `barWriter` re-marks the row after every carriage return, so a bar drawn over a redrawing progress line lands on top of its content. The rule reaches the run module too — `output.RunPrinter` bars the lines it composes and writes a job's chunks through untouched.
What the phase *keeps* is barred, and that is the whole of the distinction: `HookView` composes every line it prints — the tail included — so those go through the bar, while the cursor moves of `clear()` go to the raw stream. A bar written before one lands on the row the cursor is about to leave, survives the erase below it, and leaves every repaint one column off. `HookViewParams.Bar` is what says which writer a line takes. `DrawHookPhase` joins the run's block itself rather than leaving that to each caller: `extract` holds a presenter that may already have opened one for the port pass, and a phase that decided for itself drew an unbarred block beside a barred one.
The seam that makes it possible is worth copying for anything similar: `service/hooks` reports `domain.HookBeat` values through `flow.HookSink` — the raw output *and* the beat of each hook starting and finishing — so the surface decides what to draw and the service formats only the fallback for a caller that installed no reporter.
## Addresses are clickable
[Section titled “Addresses are clickable”](#addresses-are-clickable)
Every job address a person reads can be followed with a click, and there are two mechanisms because there are two kinds of surface.
* **Text a command prints** wraps each address in an OSC-8 link with `rules.LinkURLs`, on a terminal only (`output.IsTerminal`). A pipe, a CI log, `--output json` and machine output (`run url`, meant for `$(…)`) never receive the escape. Link **after** padding or truncating: the escape has no width, and a column measured in bytes or runes would push everything after it. `run ps` pads its ADDRESS column first, then links it.
* **A full-screen surface holds the mouse**, so the terminal never sees a plain click on a link. The run view and the dashboard open the address under a click themselves with `components.URLAt`, which reads it off the frame they last drew, so an address is clickable wherever it lands without declaring a zone. A truncated address (ending in `…`) is never followed. The run view also draws OSC-8 links (Params.Hyperlinks) for the terminal's modifier-click; the dashboard does not, because bubblezone measures the escape as text and would shift every zone on that row.
## Adding a command
[Section titled “Adding a command”](#adding-a-command)
1. Pick the form: an act (A) or a readout (B). If it is neither, it is a table or a machine contract.
2. Frame once. Write to the writer the frame hands you.
3. For each block you are about to add, answer the one question. If it does not change what the reader does next, it is a count.
4. Use the glyph vocabulary. If none fits, the line is probably accounting.
5. If stdout is the command's contract, annotate it with `domain.AnnotationMachineOutput`.
# Named URLs — the vocabulary
Three different things were being called "the proxy": the server that makes names resolve, the OS rule that removes a port from those names, and the port either of them happens to use. They are separate mechanisms with separate failure modes, and confusing them makes every message about them unreadable. This page fixes the words. Code, constants and CLI copy use these and no synonyms.
## The seven terms
[Section titled “The seven terms”](#the-seven-terms)
| Term | What it is | Where it lives |
| -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------- |
| **the proxy** | The reverse proxy the daemon runs. It listens on one port and routes by `Host` header to the job answering under that name. **This is what makes names exist at all.** On by default; `[proxy] enabled = false` in the global config switches it off | `internal/service/proxy` |
| **bind port** | What the proxy actually listens on: `11080`, or the next free port when that one is taken (the R7 fallback) | `rules.ProxyPort`, then the daemon's own fallback |
| **privileged redirection** | The OS rule `wtm run proxy install` installs — on macOS a launchd agent binding `:80` and relaying to the bind port. **It only removes the port suffix from a URL.** It unlocks nothing | `internal/service/proxy/redirect*.go` |
| **public port** | What a URL *announces*, as opposed to what anything binds: nothing (i.e. `:80`) when the redirection is live, the bind port otherwise | `rules.PublicPort` |
| **route host** | `...localhost` — the name a published job answers under | `rules.RouteHost` |
| **origin** | `scheme://host[:port]` — the thing a browser sends in `Origin:` and CORS compares. The port is part of an origin; it is **not** part of a cookie's origin | `rules.JobOrigin` |
| **addressing** | `ports` or `names`: which of the two an `[[env_port]]` link writes into a `.env` value, and which of the two a run announces | `rules.EffectiveAddressing`, `rules.RunProxyPort` |
## What follows from the distinction
[Section titled “What follows from the distinction”](#what-follows-from-the-distinction)
**The proxy makes names work. The redirection makes them pretty.** `*.localhost` resolves to `127.0.0.1` natively (RFC 6761), but resolving is not answering — without the proxy listening, a name gives `connection refused`. Without the redirection, the same name works perfectly, carrying `:11080`.
| State | What a `.env` holds | Works? |
| --------------------------------------- | ------------------------------------------------ | ------------------ |
| proxy on, redirection **not** installed | `http://api-dev.feat-x.monorepo.localhost:11080` | yes |
| proxy on, redirection installed | `http://api-dev.feat-x.monorepo.localhost` | yes |
| proxy off (`enabled = false`) | `http://localhost:4011` | yes, without names |
**A port in the URL costs nothing that matters.** CORS compares whole origin strings, so as long as both sides carry the same one — port included — it passes. And cookie isolation, the reason named URLs exist, keys on the **host** and ignores the port entirely: `feat-x` and `feat-y` are separate jars whether or not `:11080` is there.
**A run announces what the `.env` files spell.** Under `ports` a run is opened with no proxy port (`rules.RunProxyPort`, through `seam.ProxyPortsFor`): it registers no route, and `run up`, `run ps`, `run url`, `run open` and the run view's reach pane hand out `http://localhost:` — while the machine's proxy keeps running for any other project. Announcing the name there would point the reader at an origin the app's CORS settings and API urls know nothing of.
**Addressing is a project setting, the proxy is a machine setting.** `run.toml` says what the project wants; the global config says what this machine can do. When the machine cannot honour it — proxy off, no public port — ports are written and a notice says so. Writing a name nothing serves would produce a syntactically perfect, dead value.
## Addressing in `run.toml`
[Section titled “Addressing in run.toml”](#addressing-in-runtoml)
```toml
addressing = "names" # the default — omit it
```
Under `names`, an `[[env_port]]` link writes the job's full origin instead of its port number, but **only when two conditions hold at once**:
1. the job it names **publishes a url**, and that url publishes the very port the link follows;
2. the value in the `.env` **has the shape of a URL**.
Both are load-bearing. The first excludes Postgres — `DATABASE_URL → docker-compose.POSTGRES_PORT` has no name and never will, since the proxy only speaks HTTP. The second excludes the binding keys: `apps/api/.env: PORT → api-dev.PORT` names a published job and must stay a bare number. Neither condition alone is enough.
```plaintext
apps/api/.env PORT=4001 → 4011 (bare number)
apps/web/.env VITE_API_URL → http://api-dev.feat-x.monorepo.localhost:11080
CORS_ORIGIN → http://web-dev.feat-x.monorepo.localhost:11080
DATABASE_URL → …@localhost:5442/db (no name)
```
`addressing = "ports"` is the escape hatch, and it is a real inverse: a value wtm wrote as an origin is recognised as such and rendered back to `http://localhost:`.
`wtm run addressing` is how it is switched (`flow/run/addressing`, `Switch`). Its two questions are steps of one session — the mode, then whether to settle the worktrees — so the wizard can go back from the second to the first. The second reads every worktree's plan under the mode just picked, **before** run.toml is written: `ResolveEnvPortsParams.Addressing` overrides the file's, and the count is cached per mode so going back does not reread every `.env`. The settle itself is `envports.Settle`, the pass `create` and `extract` run, so the three cannot write different values.
Which worktrees it takes is the one place the main checkout is treated apart, and the rule is `rules.BulkSettlesMain`: a pass over every worktree brings main back to ports, and never moves it onto names. See the next section for why main is left alone; the asymmetry is what keeps the two commands consistent with it:
| Command | Linked worktrees | Main |
| -------------------------- | ------------------- | ------------------------------------------------------------ |
| `wtm env ` | aligned on the mode | aligned on the mode — naming it is the choice |
| `wtm run addressing ports` | settled | settled: ports is the state main has without wtm |
| `wtm run addressing names` | settled | left as is, and said so — `wtm env main` is its own decision |
Without the return leg, `wtm run addressing ports` after a `wtm env main` would leave main on names under a project that says ports, and silently: the drift warning only reads a project on names. The condition lives in the command's choice of worktrees, not in the pass — the plan and the drift reading stay free of any main-shaped condition, as below.
## Recognising wtm's own writing
[Section titled “Recognising wtm's own writing”](#recognising-wtms-own-writing)
A value already carrying a route host is recognised **structurally** — the authority matches `...localhost` — rather than remembered in a state file. One primitive then answers four questions that would otherwise each need their own mechanism:
* a second `wtm env` recomputes the same origin, so nothing is rewritten twice;
* a `.env` copied from main or a parent carries that worktree's segment, which is corrected to this one;
* installing the redirection after the fact changes the public port, and the next pass drops the `:11080`;
* switching to `addressing = "ports"` knows which values were wtm's to undo.
A state file would be more precise on paper and worse in practice: it diverges the moment somebody edits a `.env` by hand.
## Dev servers and the `Host` header
[Section titled “Dev servers and the Host header”](#dev-servers-and-the-host-header)
A named URL reaches the job with a `Host` the dev server has never seen, and some servers refuse one they do not recognise. Two of them matter here, and they do not need the same thing:
* **Next** refuses it unless the host is listed in `allowedDevOrigins`. `wtm run up` reports the missing entry when it serves a job whose directory holds a `next.config.*` without one (`rules.NeedsDevOrigins`, `rules.DevOriginsPattern`).
* **Vite** needs nothing. Its host check allows `localhost` and **every** `.localhost` subdomain outright, before `server.allowedHosts` is even consulted — verified in Vite 8.2.2, `isHostAllowedInternal`. There is nothing for wtm to detect or report.
Anything else is a report from a user, not a guess from us: the presence of a config file is the signal, never a framework inferred from a command line.
## The trade the mode makes
[Section titled “The trade the mode makes”](#the-trade-the-mode-makes)
Under `names`, **the named URL becomes the only working entrance.** Opening `localhost:5183` directly sends an `Origin` the API no longer knows, and CORS blocks it. `wtm run url` and `wtm run open` hand out the right link; a bookmark on the raw port stops working. That is the cost of having two worktrees stay logged in at the same time.
## The main checkout is not a special case
[Section titled “The main checkout is not a special case”](#the-main-checkout-is-not-a-special-case)
The spec that introduced `names` said the main checkout stays on ports. **No code enforces that, and none should.** Nothing in the port pass tests the ordinal, the base branch, or whether a checkout is the main one: `worktree.List` comes from `git worktree list`, which includes it, so `wtm env main` is accepted and writes named origins like anywhere else. The port substitution is the identity there (offset 0); the origin rewrite is not — it replaces an authority, which has nothing to do with the offset.
What "main stays on ports" really means is that **no command provisions it**: `create` and `extract` write the new worktree, and there is no such event for main. That is a gap in the lifecycle, not a guard in the code — so the answer is a warning, not a refusal.
`rules.PendingOriginRewrites` counts what a `wtm env` on a worktree would still move onto a named origin, `rules.AddressingDriftLine`/`Lines` phrase it, `worktree.EnvPortPlanFor` computes the plan without applying it (the same one `wtm env` writes, so the two cannot disagree), and `flow/run/addressing` is what the run flows read. They hand the lines to the **surface**, which renders them once where they can be seen: a band in the run view, a callout beside a stream, nothing at all on a machine run. A notice printed after the view reaches a reader who has already followed the URL.
Two things the count is deliberately not. It is not "the .env holds ports": a value already carrying a named origin whose public port went stale — every worktree, the moment `proxy install` moves 11080 to 80 — is pending too, and equally broken, which is why the wording says *out of step* rather than naming ports. And it is not "the app is broken": wtm sees only the keys declared as `[[env_port]]` links, and cannot know whether the app makes a cross-origin call at all. The reading names no worktree in particular: a linked worktree whose port pass was declined is in the same state, and main is only the one that is there by construction.
Do not add a main-shaped condition here.
**The address a surface hands out is the name, whatever the `.env` spells.** It used to follow the file — a worktree still on ports was handed `http://localhost:` everywhere — and the result was that the one URL a reader came for disappeared on the checkout they use most, with the reason in a callout at the very bottom. Now every surface hands out the published name, and a worktree whose `.env` is out of step gets **one** `!` line naming the command that aligns it (`rules.AddressingDriftLine`; `rules.AddressedByPort` only picks which of its two sentences). The trade is explicit: until that command runs, a cross-origin call made through the name is refused, and the line is what says so. `--raw` on `run url` / `run open` still gives the port.
## Where to reach it — one model for every surface
[Section titled “Where to reach it — one model for every surface”](#where-to-reach-it--one-model-for-every-surface)
A run says where each job is reached in **one** place, `rules.ReachBlock` over `domain.ReachEntry` (`internal/rules/reach.go`): the URLs first — a runner's by the apps it holds — then the jobs reached by port, their ports named (`REDIS_PORT` → `redis`) and wrapped in balanced columns, a shared job's namespace beside it. Every surface renders that block at its own density, so none of them decides on its own what to show:
| Surface | Line / title | The block |
| ---------------------------- | ----------------------------------------------------------------------------------- | ----------------------------------------------------- |
| stream (`run up -d`, a pipe) | one fragment per job — the URL, `:5432`, `3 urls`, `6 ports` (`rules.ReachSummary`) | concludes the run, before the `!` lines and the hints |
| run view | pane title `job · status · fragment` | behind `a`, and in the recap left on exit |
| `run ps` | `ADDRESS` column, `WORKTREE` as the branch | — |
A shared service a linked worktree only holds runs in main, and every surface says so the same way: `postgres joined, running in main` on its line, a **Shared, running in main** section in the block (`rules.ReachSections`), a `shared` line above it in the run view's job list, and a pane title naming where it runs rather than the worktree holding it. Stopping the worktree leaves those running — which is the one thing the reader has to be able to see.
## Related
[Section titled “Related”](#related)
* `internal/rules/envorigins.go` — the whole origin surgery, pure and testable
* `internal/rules/envports.go` — the port substitution it sits beside
* [architecture.md](/0-28/dev/architecture/) — which layer may call which
# Shared services — one instance for the repository, one namespace per worktree
The `run` module gives each worktree its own stack. That is the point, and it is also what makes a real monorepo expensive: two worktrees of a project with four postgres containers and a keycloak mean eight postgres and two JVMs. Some of those services have no reason to be duplicated, and `scope = "shared"` is how a project says so.
The measurement that motivated it is worth keeping: on the machine that prompted the work, CPU was 93% idle while memory sat at `587M unused` with `3230M compressor`. The constraint is memory, and the only variable wtm controls is the number of instances.
## The principle
[Section titled “The principle”](#the-principle)
wtm cannot know every provider. It provides **surfaces**: it runs a command at the right moment and injects the right environment, giving the command access to what only wtm knows — the worktree's ordinal, its ports, its URLs. What that command does to a postgres or a keycloak is the project's business.
Any question this document leaves open is settled with that sentence. If the answer requires wtm to learn what a realm is, it is the wrong answer.
## Where a shared job runs
[Section titled “Where a shared job runs”](#where-a-shared-job-runs)
`jobKey(name, workDir)` is untouched. The sharing is entirely in the choice of work dir.
The real service runs in the **main checkout** — the worktree `domain.GitWorktree.IsMain` designates — registered under `:`: a real PTY, real ports, a real output hub. It is the only directory guaranteed to live as long as the repository, and being at ordinal 0 it takes **no port offset**, so a declared `5432` is the `5432` it binds. That stability is what lets a namespace's `env` write its URL literally.
Every other worktree posts a claim under `:`, with status `domain.JobStatusJoined` (`joined`; a daemon or index from before the rename says `attached`, read as `joined`): no PID, no PTY, no hub, no log file. It is a pointer, and it is the reference count. **The job table is the count**, so there is no second registry to keep in step, and the statestore already persists it — a claim carries `Joined` in its record so it comes back from the index as what it is rather than as a foreground service the daemon had lost.
A claim owns no stream: attaching the run view to one from any worktree reaches the one output there is.
A claim is dropped when its service is no longer up, and `Manager.Adopt` is where that is enforced: a daemon killed without running a handler may leave a shared foreground service to be reaped at the next start-up, and a claim outliving it would be a reference count on nothing — the next worktree would be told its service is already running. The pass runs on every adoption, not only after a reap, because the same hole opens whenever the real job's record is gone and the claims' are not (a deleted main checkout, for one). See LUC-227.
Releasing a claim stops the service only once no worktree holds it. Two worktrees racing to start the same service both succeed — `run up --all` fans out, and losing that race is not a failure. A shared job whose main checkout the client could not resolve is **refused**, never run once per worktree.
## The namespace
[Section titled “The namespace”](#the-namespace)
`[job.namespace]` is the worktree's namespace in the shared service, and it is **singular**. It does not name an object of the provider: four keycloak realms are one namespace, whose internal shape belongs to the create script. That is the direct consequence of the principle above, and it is what keeps a list of realms or databases out of the TOML.
Two mechanisms, not two spellings of one:
* `{worktree}` and `{ordinal}` are **wtm's own substitution into data** — `namespace.name` and `namespace.env` are never executed, and wtm fills them in before anything runs;
* `$WTM_WORKTREE`, `$WTM_ORDINAL`, `$WTM_NAMESPACE` are **environment variables**, expanded by `/bin/sh` when `create` or `remove` runs.
`$WTM_WORKTREE` in a `name` would expand to nothing: no shell ever sees a name. Making wtm expand it there would be worse — it would look like shell syntax while only three variables worked, so `$HOME` would silently fail beside it. The step therefore offers each row only what that row takes.
`attach` and `detach` run with the **worktree's whole resolved environment** — ports and URLs included. That is what makes keycloak possible at all: a realm's `redirectUris` point at the fronts of the worktree asking for it, and the script needs those URLs. Without that access the design would handle postgres and leave keycloak stranded.
`create` runs on **every** start of the shared service, not once — wtm keeps no ledger of having run it, and a ledger would be wrong the moment the data went away behind wtm's back (`docker compose down -v`). So the command must be safe to run again: create the namespace if it is absent, do nothing if it is there. That is the whole contract, and it is stated where the command is written — the schema, the `run init` step, and the failure message.
It is retried within `domain.NamespaceCreateTimeout`: the service it talks to was started moments ago, so a first refusal means "postgres is not accepting connections yet" far more often than it means the command is wrong. The budget is what stops a genuinely wrong command retrying for ever. It cannot tell a refusal that will pass from one that never will — which is exactly why the idempotence is the command's job and not wtm's guess.
The daemon is what runs it, and a daemon that considered itself idle while doing so used to exit under its own handler: a shared service launches detached, so nothing is left `Running` to keep it alive. The idle watcher counts connections in flight beside the running jobs.
An absent `[job.namespace]` is a valid answer: shared for good, one instance and one set of data.
### Reaching the app: the `[[env]]` link
[Section titled “Reaching the app: the \[\[env\]\] link”](#reaching-the-app-the-env-link)
Carving a namespace out is half the work. The app has to be told which namespace is its own, and that is not something a port can say.
`[[env_port]]` rewrites **the port inside** a value and leaves the rest alone, which is what lets a password live in a `.env` and never in `run.toml`. It can express "the shared keycloak answers here" and nothing else. A realm name is opaque — no number, no shape, nothing to anchor a substitution on.
So a second table, `[[env]]`, writes a key's **whole** value from a template:
```toml
[[env_port]] # the shared instance: one address for all
file = "apps/web/.env"
key = "KEYCLOAK_URL"
job = "keycloak"
port = "KEYCLOAK_PORT"
[[env]] # the namespace: one per worktree
file = "apps/web/.env"
key = "KEYCLOAK_REALM"
job = "keycloak"
value = "{namespace}"
```
That is the line between the two, and it is worth stating once: **`[[env_port]]` says where the service answers, `[[env]]` says which namespace in it this worktree holds.** A shared service has one address for every worktree — its published host carries no worktree segment and its port takes no offset — so the first is not per-worktree at all.
The vocabulary is closed: `{namespace}`, `{port.NAME}`, `{origin}`, `{worktree}`, `{ordinal}`. Anything else is refused when `run.toml` is read, against a stand-in worktree, so a typo is caught for every worktree at once rather than the first time one is created. `{port.NAME}` goes through `rules.ResolvedPort`, the one place that answers what a declared port becomes in a worktree — deriving it a second time here is exactly how a report once said 5432 while the file was written 5452.
A resolved link becomes a `domain.EnvOwnedEntry`, which is the mechanism that already existed for `COMPOSE_PROJECT_NAME`: wtm owns the line, plans it, reports it when it changed, and takes it out of the reconciliation's verdict. A key wtm writes in full differs from its source by construction, so calling it a conflict would have every `wtm env` offer to undo the isolation it had just set up.
A key may not be written by both tables. They are not complementary — an `[[env]]` value writes its own port when it needs one (`postgresql://app:app@localhost:{port.POSTGRES_PORT}/{namespace}`), so a key both claim is a line to delete rather than a merge order to invent. It is refused at load, naming both.
Nothing here needs the daemon: `{namespace}` is `name` with `{worktree}` substituted, known without running anything. So the links settle at the same moments the port links do — when a worktree is created, and on `wtm env` or a `sync` reconciliation.
### `run init` and the keys it cannot detect
[Section titled “run init and the keys it cannot detect”](#run-init-and-the-keys-it-cannot-detect)
A port is detectable: the key is named `PORT` or `*_PORT`, the value is a number, and it matches a port a job declares. Three signs agreeing. `KEYCLOAK_REALM=myapp` has none of them — a realm name is an opaque word — so **the step asks**, and every managed key is a row. Filtering the list would hide the only key the reader wanted.
Two things narrow it without wtm pretending to know what a realm is:
* **A key whose value carries a port the service binds is its address, never its namespace.** That is the `[[env_port]]` table's business, and the signal is structural rather than a guess about the key's name. It works on a first init, where no link exists yet. A key an `[[env_port]]` already writes is excluded for the same reason.
* **A key whose name starts with the job's own name is pre-checked** — `KEYCLOAK_*` beside a job called `keycloak`. That is a deduction from a name the user chose, not knowledge of the service.
On the pair that motivated the design, the two rules split it exactly: `KEYCLOAK_URL` holds `8080` and stays with the port table, `KEYCLOAK_REALM` is pre-checked and becomes an `[[env]]` link. Everything else is offered, unchecked, with the value it holds today beside it.
The one proposal wtm makes for a template is `{namespace}` — the same decision as `app_{worktree}` for the name, and as the two commands it proposes nothing for. Editing a template links its row: editing is asking for it to be written.
The step **migrates rather than stacks**. Marking a key that an `[[env_port]]` link already writes takes that link off, since the two are refused together at load — a wizard that wrote both would produce a config wtm then refuses to read, which is the worst outcome a wizard can have. The pruning happens once both tables are complete: the init pipeline settles the values and then appends more port links, so the last word is taken after that append.
Re-init is symmetric like every other step (`EnvValuesAsked`, the same `(value, asked)` pair): unchecking every row withdraws every link the step offered, a run that never asked leaves run.toml standing, and a link on a file the step never showed — one `config.toml` no longer configures — survives untouched. A step may only remove what it proposed.
### Knowing a namespace exists
[Section titled “Knowing a namespace exists”](#knowing-a-namespace-exists)
A claim goes with a `run stop`, so it cannot be what tells `clean` there is a database to drop. The worktree's own `meta.json` carries `namespaces`: the shared services it has actually carved a namespace out of, recorded the moment each shared service reports started — not at the end of the sequence, since a `run up` interrupted after the create would otherwise leave a database nothing records. A write that fails is a warning on the run (`PhaseWarning`), never silence: a namespace nobody wrote down is one no clean will drop. It lives there because the file is removed with the worktree it describes, and because both wrong answers are bad — giving back a namespace that was never created runs a `DROP DATABASE` on nothing, and missing one leaks a database on every iteration.
A worktree created and thrown away without ever starting the stack therefore owes nothing.
### Writing the two commands
[Section titled “Writing the two commands”](#writing-the-two-commands)
`run init` asks. After the scope step, a step lists three rows per shared service — its name, its `create`, its `remove` — and only the name carries a proposal. wtm has nothing honest to say about the other two: a recipe for postgres would guess the port variable, the user, the host and whether `psql` is even on this machine, and a pre-filled command that is accepted and then fails inside the retry budget reads as a wtm bug rather than as a line to write. It is the same decision LUC-55 already recorded for the port flag of every framework.
What wtm *does* know it shows, while the field is open — grouped by where it comes from, since one run-on line stops being readable as soon as a job declares more than one port:
```plaintext
available
worktree $WTM_NAMESPACE $WTM_WORKTREE $WTM_ORDINAL
ports $CRM_DB_PORT $CRM_ADMIN_PORT
```
The first row is the same everywhere; the second is the ports **this job** declares, under the names it declares them by. A long group wraps under its own first variable rather than repeating its label. Both an inline command and the path to a script are accepted — both are a `/bin/sh` line run in the worktree.
An empty `create` is an answer, not an omission: the service is then shared outright, data included.
Outside `run init`, `run job add` and `run job edit` declare the same thing: `--scope shared|worktree`, `--namespace-name`, `--namespace-create`, `--namespace-remove` and `--namespace-env KEY=VALUE`, and their form asks the same fields — the namespace ones only once the scope is shared. There an empty *name* is the "shared outright" answer, so a named namespace must have a `create`: the loader refuses a block with one and not the other (`rules.HasNamespace`), and every write goes through the same validation (`runconfig.Save`).
### Starting a namespace from main's data
[Section titled “Starting a namespace from main's data”](#starting-a-namespace-from-mains-data)
What makes isolation feel expensive is rarely the namespace itself — it is an empty database to migrate and seed, a realm to rebuild by hand. That cost belongs in `create`, not in wtm: **clone the data main uses instead of creating an empty namespace.** The worktree then starts where main is, and still owns its copy — nothing it migrates or resets reaches main, which is exactly what sharing main's data outright could not promise.
For Postgres it is one statement, guarded because `create` runs on **every** start of the shared service:
```sh
#!/bin/sh
# scripts/db-worktree-add.sh — the [job.namespace] create of a shared postgres.
set -e
psql="psql -h localhost -p $POSTGRES_PORT -U postgres -v ON_ERROR_STOP=1"
exists=$($psql -tAc "SELECT 1 FROM pg_database WHERE datname = '$WTM_NAMESPACE'")
[ "$exists" = 1 ] && exit 0
$psql -c "CREATE DATABASE \"$WTM_NAMESPACE\" TEMPLATE app"
```
Three things to know about it:
* **`app` is the database main's `.env` actually names**, not the namespace wtm would give main: main predates wtm, and its `.env` is only rewritten by a `wtm env main`.
* **`TEMPLATE` refuses a source with open connections.** Stop main's backend while the clone runs, or trade the instant copy for `pg_dump app | psql "$WTM_NAMESPACE"` after a `CREATE DATABASE`, which copies around them.
* `$POSTGRES_PORT` is there because the command gets the job's own ports under the names it declares them by, next to `$WTM_NAMESPACE`, `$WTM_WORKTREE` and `$WTM_ORDINAL`.
A Keycloak realm follows the same shape: export main's realm, rewrite its name to `$WTM_NAMESPACE`, import it — skipped when the realm already exists.
## A job that changes someone else's data
[Section titled “A job that changes someone else's data”](#a-job-that-changes-someone-elses-data)
A namespace protects a worktree's data only as long as the jobs it runs write to that namespace. Two cases break that on purpose: a **verbatim** worktree, whose `.env` names its source's databases, and a shared service with **no** `[job.namespace]`, which holds one set of data for every worktree. A profile running `orm:reset` there resets someone else's database.
wtm cannot see that from a command, so the job says it: `touches = ["postgres"]` names the services whose data it changes. `run init` asks it in its *Data tasks* step, after the runners: one row per task, cycling through the services that hold data (the shared ones and the compose stacks) under the names the configuration being built gives them. `rules.TouchChoices` pre-sets a row only from what run.toml already says, or when the task's name carries a data verb and shares a word with exactly one service — `orm:pay:reset` and `postgres-pay`; anything less certain is left on none for the reader. The step reuses the runner list, which already cycles one job name per row. `rules.ForeignDataRisks` reads those declarations against the worktree's isolation — from `WTM_ISOLATION`, the same answer the daemon acts on — and `internal/flow/run/foreigndata` stops `run up` and `run start` before the job starts:
| Surface | What happens |
| --------------------- | ------------------------------------------------------------------------------------ |
| a terminal | *Run them anyway* / *Don't start*, naming each job, the service and whose data it is |
| `--yes`, JSON, no TTY | refused, naming `--force` and `wtm env --isolation isolated` |
| `--force` | let through without a question — the safety axis, never implied by `--yes` |
The main checkout is never stopped: it owns its data, and every other checkout is either carved beside it or copied from it. A job with no `touches` is never stopped either — the guard reads what the config declares and nothing else, so a project that declares nothing keeps the behaviour it had.
## Stopping is not destroying
[Section titled “Stopping is not destroying”](#stopping-is-not-destroying)
`run stop` and `run down` never run `detach`. A `run down` that dropped a database would make the command unusable.
The detach belongs to `clean` and `prune`, and `flow/teardown` fixes where it sits in a removal. Per worktree: (1) the worktree's own jobs are stopped **and checked gone** — its claims stay — and a job still up refuses the removal unless `--force`; (2) the `on_clean` hooks run; (3) git removes the worktree; (4) only then is the namespace dropped; (5) the claims are released. Any failure before (4) leaves the data where it was: dropping first, as clean once did, lost the database of a worktree whose hook then failed, and the next `run up` recreated it empty. Stopping the jobs first is also what lets the drop through at all — an API still connected to its database is exactly what `DROP DATABASE` refuses. The claims go last because releasing the last one stops the service, which could then take nothing back; `prune` releases them all once every drop is done, since one worktree's claim may be what keeps the service up for the next one's. It runs the whole sequence on a worktree before starting the next, and stops at the first that fails.
The drop runs from the project directory, the worktree's own being gone, with the environment read while it existed. Its `remove` command is bounded by `domain.NamespaceRemoveTimeout` — a drop waiting on a lock nobody releases would otherwise hold the clean for ever — and a drop past it, or refused by a service that is up, is owed like one whose service is down, with its real cause said. A drop that succeeds settles any older debt for the same namespace. A namespace another live worktree reaches under the same slug (`feat.x` beside `feat/x`) is never dropped: it is that worktree's too.
`git worktree remove` drops its own entry even when it could not delete every file (root-owned files a container wrote). That removal is completed rather than left half-done — branch deleted, state purged, data dropped — and the leftover directory is named with the `sudo rm -rf` that deletes it. A removal git refused outright (a locked worktree) removed nothing, and keeps the data.
The default is to detach, since `clean` is the destructive command and removing a worktree without its data would leave an orphan behind on every iteration; `--keep-data` withholds it, under `--yes` as much as anywhere.
A service already down leaves a debt rather than being relit behind the reader's back for a `DROP DATABASE`. The debt lives in `/wtm/pending-removals.toml`, beside the repository, because the worktree's own state directory is exactly what `clean` removes. It is a **queue and not a registry** — entries are only ever added by a failure and removed by a success.
The debt is paid wherever the service is next seen up, by one piece of code, `flow/run/owed`:
* **`run up` and `run start`** settle it after their sequence, from any worktree. Cleaning late, with nothing running, is the common case, and waiting for someone to remember `prune` while a stack happened to be up is how debts piled up.
* **`clean` and `prune` ask, in their form**, before the recap — a *Data* step (`owed.DataStep`) shown only when a service holding the removed worktrees' data is down: start it and drop the data now, or keep it until it next starts. One question for every service down, never one per service, and never after the confirmation: the recap is the last action point, and it says what happens to each namespace — dropped, dropped after starting its service, or kept. Starting brings the service up from the main checkout (`owed.BringUp`), drops the namespaces, and releases main's hold — the service stops again unless a worktree took a claim meanwhile. `--yes` keeps deferring: starting a service nobody asked for is not a safe default. `--drop-data` answers the step ahead (a preset, so the recap still says which services it starts), which is how an unattended run — an agent's — asks for the drop; it excludes `--keep-data`. The step exists because a debt is only paid by a service **wtm** starts: someone who runs their database some other way would otherwise keep every deferred database forever.
* **`prune`** also settles older debts before its own removal, and says what is still owed (`2 namespaces still owed to postgres — dropped on its next start`) instead of passing over it.
Both commands go through `owed.Read` (what the worktrees hold, read while they still exist) and `owed.Dropper` (bring up what is down, drop one worktree's namespaces under a stage once it is gone, report, queue the rest), inside `flow/teardown`, so they cannot drift apart. Their `--output json` carries each namespace as `dropped`, `deferred` or `kept`.
A debt whose worktree **exists again** is withdrawn, never paid: the namespace is derived from the worktree's name, so it now belongs to the re-created worktree, and paying the debt would drop that worktree's data.
## `run init` and the compose granularity
[Section titled “run init and the compose granularity”](#run-init-and-the-compose-granularity)
wtm generates **one job per compose file**, not per service. A scope had therefore nothing to sit on: a single `docker-compose.yml` with eight services was one job.
So the scanner reports what each file declares (`domain.ComposeScan.Services`, with `Image` and `HasBuild`), the step enumerates **services**, and marking one shared **lifts it into a job of its own** (`docker compose -f up -d `, stopped with `stop ` and never `down`, which would tear the whole file apart). The file's own job then names the services that stayed, since `docker compose up` would otherwise start the lifted one a second time. A file with nothing left keeps no job.
The step sits **before** the ports step: a shared job takes no offset, so which services are shared must be settled before their ports are.
A service with a `build:` is shown with its reason and no answer to give. That is structural, not a guess about the image's name — such a service compiles this worktree's source, so sharing it would serve one worktree's build to all of them.
Where `run.toml` has an opinion it outranks detection, and a run that never put the question leaves what it declares standing (`ScopesAsked`, the same `(value, asked)` pair as `URLsAsked` and the others).
## What a shared service changes on the surfaces
[Section titled “What a shared service changes on the surfaces”](#what-a-shared-service-changes-on-the-surfaces)
* Its published host carries **no worktree segment** (`db.projet.localhost`). One instance cannot answer under two names, and keeping the segment would have two worktrees' `.env` files disagree about where a single service answers.
* Its compose volume and network names need no special handling: they are already templated `${COMPOSE_PROJECT_NAME:-default}`, and a service running in the main checkout inherits that checkout's project name. That name must therefore not carry the main's branch, or every checkout on the main starts a second shared stack and orphans the first with every worktree's namespace in it: `BranchEnv` names ordinal 0 by `rules.MainComposeProjectName` — the `COMPOSE_PROJECT_NAME` of the main's own `.env`, else the repository's slug, which is what `docker compose` picks there itself — and never reads the client's environment for it, since that belongs to whichever worktree the command ran from. `ResolveEnvPorts` writes the same name, so a compose run by hand in the main lands on the stack wtm started.
* A claim reports `pid: 0` and its own mark. Printing a PID beside three worktrees would read as three processes.
* A claim is **attachable**: it owns no stream, and the daemon resolves it to the one there is — so `run logs` works from any worktree. Its persisted tail is read from the main checkout's log directory, not from its own.
* Both the real job and every claim carry the main checkout they belong to. The daemon is machine-wide, so matching a claim to its service by name alone let two repositories that both declare `db` release each other's.
## What it costs when nothing is shared
[Section titled “What it costs when nothing is shared”](#what-it-costs-when-nothing-is-shared)
Nothing. `sharedContext` is resolved once per seam and only when `run.toml` declares a shared job — otherwise every `run` command, `run ps` included, would pay a `git worktree list` plus a full environment resolution for the main checkout.
# wtm user guide
How wtm works beyond the first steps: its configuration, and the `run` module with the per-worktree isolation it rests on. They explain how the pieces fit together; the flags of each command live in `wtm --help` and in the generated [command reference](/0-28/reference/wtm/).
New to wtm? Start with [Getting started](/0-28/guide/getting-started/), then pick a setup from [Recipes](/0-28/guide/recipes/).
| Page | What it covers |
| -------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [Getting started](/0-28/guide/getting-started/) | a ten-minute tutorial: install, two worktrees running side by side, cleaning one |
| [Recipes](/0-28/guide/recipes/) | complete setups: a pnpm/turbo monorepo, a docker compose app, a shared postgres, AI agents in parallel, stacked PRs |
| [Troubleshooting](/0-28/guide/troubleshooting/) | a port in use, a crashed job, the daemon's version, `wtm go`, exit 16, named URLs, `.env` drift |
| [Configuration](/0-28/guide/configuration/) | `config.toml`, env strategies, hooks, the global config, editor autocomplete |
| [Isolation: isolated or verbatim](/0-28/guide/isolation/) | how a worktree stands against its source, `COMPOSE_PROJECT_NAME`, adopting isolation on an older worktree, `touches` and foreign data |
| [Jobs, profiles and runners](/0-28/guide/jobs-and-profiles/) | services and tasks, what `run up` starts, runners, the run view and `-d`, port checks, `run ps` statuses |
| [Shared services and namespaces](/0-28/guide/shared-services/) | one instance for the repository, a namespace per worktree, `[[env]]` links, what `clean` and `prune` drop |
| [Named URLs and addressing](/0-28/guide/addressing/) | the run proxy, named and port URLs, `url.host`, the `addressing` mode, port 80 on macOS |
| [How `wtm run` works](/0-28/guide/how-run-works/) | the job environment (`WTM_*`, `COMPOSE_PROJECT_NAME`), ports and the port check, what `run init` proposes, ports and addresses in a `.env`, compose names |
| [`run.toml` reference](/0-28/guide/run-toml/) | every key of the file, with its default |
| [Where wtm keeps its state](/0-28/guide/state/) | the files under `/wtm/` and beside the global config |
| [Migrating to 0.28](/0-28/guide/migrating-to-028/) | what changed for a v0.27 user, and what to do about it |
The module is opt-in: nothing here applies until `wtm run init` writes `run.toml`. Until then, the `run` commands that need it refuse (exit `16`) and point at `wtm run init`.
# Named URLs and addressing
## Two ways to reach a job
[Section titled “Two ways to reach a job”](#two-ways-to-reach-a-job)
* A **port URL** is the job's own port: `http://localhost:4012`. Every worktree binds its own port, so two worktrees never collide, but they share `localhost`, and with it the browser's cookie jar and every CORS origin.
* A **named URL** is served by the **run proxy**, which lives in the run daemon: `http://api.feat-x.myrepo.localhost:11080`. Each worktree gets its own hostname, so two of them stop sharing a cookie or an origin. `*.localhost` names the loopback in browsers and most HTTP clients, so nothing is added to `/etc/hosts`.
A job opts into a name with a `url` table naming the declared port that speaks HTTP:
```toml
[[job]]
name = "shop-api"
kind = "service"
cmd = "pnpm dev"
[job.ports]
PORT = 4001
[job.url]
port = "PORT" # the declared port the proxy forwards to
host = "api" # optional: the first label, the job's name when absent
```
The host is `...localhost`: `` is the branch as a DNS-safe slug, `` the repository's directory name. A [shared service](/0-28/guide/shared-services/) has one address for the whole repository, so its host carries no worktree segment. `wtm run init` offers a name to every service that declares the port it listens on; `wtm run job add|edit --url-port PORT --url-host api` set it by hand.
`wtm run url [branch] --job ` prints a job's named URL (`--raw` the port URL) for `$(…)`; `wtm run open` hands it to the browser. `run up`, `run ps` and the run view show the same addresses.
## The proxy's port
[Section titled “The proxy's port”](#the-proxys-port)
The proxy listens on the loopback only, on port `11080` by default, set in the [global config](/0-28/guide/configuration/#global-config):
```toml
[proxy]
port = 11080 # 0 or absent means this default
enabled = true # false: every surface hands out port URLs instead
```
A port it cannot bind costs the names, never the jobs. `wtm run proxy status` reports the configured and real ports and whether port 80 is redirected.
On **macOS**, `wtm run proxy install` removes the port from every named URL: it installs a per-user LaunchAgent: launchd binds port 80 on the loopback and hands the socket to wtm, which relays it to the proxy. No sudo, no system file; `wtm run proxy uninstall` removes it. On other systems the named URLs keep their `:11080`.
## Addressing: what a `.env` value holds
[Section titled “Addressing: what a .env value holds”](#addressing-what-a-env-value-holds)
When a `.env` value points at another job (`VITE_API_URL`, `CORS_ORIGIN`), wtm rewrites it for each worktree through an [`[[env_port]]` link](/0-28/guide/run-toml/#env_port). `addressing` in `run.toml` decides what that rewrite writes:
* `"names"` (the **default** when the key is absent) writes the job's whole named origin, `http://api.feat-x.myrepo.localhost`, whenever the linked job publishes a URL for that port **and** the value has the shape of a URL. A bare `PORT=4011` stays a number, and a `DATABASE_URL` stays a port: the proxy only speaks HTTP.
* `"ports"` writes port numbers everywhere.
The choice has a consequence outside wtm: named URLs answer while `wtm run` runs the job, and not when you start the app yourself. A project whose author launches dev servers by hand wants `"ports"`. On a machine where the proxy is off, ports are written whatever the mode says, and a notice says so. Under `"ports"` the run surfaces also hand out port URLs and register no name.
`wtm run addressing names|ports` switches the mode and settles every worktree's `.env` onto it (`--keep-env` switches `run.toml` alone). The **main checkout** is the exception: it is the checkout that works without wtm, so a switch brings it back to ports but never moves it onto names; `wtm env main` does that, when you ask. While main's `.env` still holds ports under `"names"`, its working entrance is the port URL, and wtm says so wherever it hands out main's named URL.
# Configuration
All wtm files live under `/wtm/` (`.git/wtm/` for a normal clone). Git never commits anything inside `.git/`, so wtm is invisible to teammates and to `git status`: worktree usage stays personal.
```plaintext
/wtm/
├── config.toml # project settings
├── run.toml # dev jobs + profiles
├── schemas/ # JSON schemas for editor autocomplete
├── worktrees//
│ └── meta.json # source branch, timestamp, env strategy, ordinal, isolation, namespaces
├── logs// # each job's output, one file per job
├── hooks/ # the raw output of the last on_create / on_clean run, per branch
└── pending-removals.toml # namespace drops a clean still owes
```
Each file and each `meta.json` field is described in [Where wtm keeps its state](/0-28/guide/state/).
Everything is plain TOML, validated at load time (unknown keys are rejected, not silently ignored). Edit by hand, or use `wtm config show` / `wtm config edit` and the `wtm run import` flow.
## Project config: `config.toml`
[Section titled “Project config: config.toml”](#project-config-configtoml)
Generated by `wtm init`, per-clone, never committed.
```toml
[worktrees]
base_path = "../.trees" # where worktrees are created (relative to repo root)
base_branch = "main" # default base for new worktrees
[env]
strategy = "example" # example | main | parent (see below)
# Each detected value file and its committed template (schema). wtm distinguishes
# templates (.env.example / .dist / .sample / .template / .tmpl, committed) from
# value files (.env, gitignored). .env.local is detected and flagged local but
# stays syncable.
[[env.file]]
target = ".env"
template = ".env.example"
[[env.file]]
target = ".env.local"
local = true
[hooks]
on_create = [
"pnpm install", # string: runs from worktree root
{ cmd = "pnpm install", cwd = "apps/api" }, # object: runs from a subdir
{ cmd = "pnpm install", cwd = "apps/web", continue_on_error = true }, # non-fatal
]
on_clean = [
"docker compose down", # runs right before a worktree is removed
]
```
`on_create` hooks run after a worktree is created; `on_clean` hooks run in the worktree just before it is removed by `clean`/`prune` (e.g. to tear down external resources). A non-zero hook aborts the operation unless the entry sets `continue_on_error`. Hooks interpolate `{{worktree}}`, `{{branch}}`, `{{root}}`, and (for `on_create`) `{{from_branch}}`. A hook is a `/bin/sh` line and each value is quoted for the spot it lands in, so a path holding a `'` or a `$` reaches the command as it is spelled on disk: `cd {{worktree}}`, `cd "{{worktree}}"` and `cd '{{worktree}}'` all work.
A hook also gets the worktree's run variables (`COMPOSE_PROJECT_NAME`, `WTM_*` and the declared ports, see [Run config](/0-28/guide/how-run-works/)) **only when** `run.toml` declares a job running `docker compose` **and** the worktree recorded its isolation (`isolation` in its `meta.json`: every worktree created since the choice exists, or adopted with `wtm env --isolation isolated`). Otherwise a hook runs with the environment it always had, and resolving nothing allocates no ordinal. When the worktree's own `.env` (in the directory a compose job runs from) sets `COMPOSE_PROJECT_NAME`, that value is the one a hook gets.
## Env strategies
[Section titled “Env strategies”](#env-strategies)
| Strategy | Behavior |
| --------- | ------------------------------------------------------------------------------------------------ |
| `example` | Copies `file.example` from the main checkout, renamed to `file`. Warns if `.example` is missing. |
| `main` | Copies the actual file from the main checkout. |
| `parent` | Copies from the source worktree (`--from`), falling back to `main`. |
The strategy is recorded per worktree. Later, [`wtm env`](/0-28/reference/wtm-env/) reconciles a worktree's `.env` against its **template** (the committed schema) plus the **same value source**, adding missing keys and (with `--mode refresh`) settling values that drifted. Override the source for a single run with `--from`; the report always shows which source was used.
## Global config
[Section titled “Global config”](#global-config)
Created by `wtm init`, personal to each developer. It lives under the OS config directory (`~/.config/wtm/config.toml` on Linux, `~/Library/Application Support/wtm/config.toml` on macOS), and `wtm run proxy status` prints the resolved path.
```toml
shell = "zsh" # zsh | bash | fish
[ui]
animations = true # false disables every wtm ui animation (tab rule, new-row flash)
[proxy]
port = 11080 # where named job URLs are served, on the loopback only
enabled = true # false sends every URL back to http://localhost:
```
`ui.animations` defaults to on when absent; set it to `false` to turn off every `wtm ui` animation at once, useful over a slow or laggy connection.
`[proxy]` serves each worktree's HTTP jobs under their own hostname (`http://...localhost:11080`), so two worktrees stop sharing one cookie jar. A job opts in with `url = { port = "PORT" }` in `run.toml`, which `wtm run init` writes for the services it detects; `url.host` replaces the job's name as the first label (`url = { port = "PORT", host = "api" }`). Both keys default to the values above; the proxy lives in the background daemon and dies with it, and a port it cannot bind costs the names, never the jobs.
A job started **by another job** is served too. A root `turbo run dev` is one process holding several apps, declared as `runs = ["web", "api"]`: starting it registers the name of every published job it runs, so `http://web...localhost:11080` answers even though the only job the daemon holds is the runner. Those addresses are reported on the runner (under its line in a run, on its row in `wtm ui`), and the apps it holds get no row of their own while it is up: they are its subprocesses, not jobs beside it.
## IDE autocomplete + validation
[Section titled “IDE autocomplete + validation”](#ide-autocomplete--validation)
Every TOML file `wtm init` writes starts with a `#:schema ./schemas/...json` directive. Pair it with [Even Better TOML](https://marketplace.visualstudio.com/items?itemName=tamasfe.even-better-toml) (or the bundled JetBrains TOML plugin) for autocomplete, hover docs, and real-time validation. Schemas ship with the binary and are written beside each file every time wtm writes it (`init`, `init --only`, `run init`, `run job` / `run profile`, `relocate`…), no internet required. To refresh them after upgrading without touching a config:
```bash
wtm schema dump # /wtm/schemas/{run,project}.schema.json
wtm schema dump --global # global.schema.json, beside the global config
```
# Getting started
Ten minutes from install to two branches running side by side. The example is a small monorepo called `acme`, with a web app in `apps/web` and an API in `apps/api`, each started by `pnpm dev` and each with a `.env` copied from a committed `.env.example`:
```plaintext
acme/
├── apps/api/ package.json, .env.example (PORT=8787)
├── apps/web/ package.json, .env.example (PORT=5173, API_URL=http://localhost:8787)
├── package.json
└── pnpm-workspace.yaml
```
Your repository will differ; the steps do not. The outputs below are what wtm prints on that repository, trimmed to the lines that matter.
## 1. Install
[Section titled “1. Install”](#1-install)
```bash
brew install LucasPcq/tap/wtm
```
Or `go install github.com/LucasPcq/wtm@latest`, or a binary from the [releases](https://github.com/LucasPcq/wtm/releases). wtm needs `git`; [`gh`](https://cli.github.com) is optional and unlocks the GitHub features.
Then add the shell integration, which is what lets `wtm go` change your directory:
```bash
echo 'eval "$(wtm shell-init)"' >> ~/.zshrc # ~/.bashrc for bash
exec zsh
```
For fish, add `wtm shell-init | source` to `config.fish`.
## 2. Set up the repository
[Section titled “2. Set up the repository”](#2-set-up-the-repository)
```bash
cd acme
wtm init
```
The wizard proposes where worktrees go, the base branch, how `.env` files are provisioned and which hooks to run (`pnpm install`, say). Accepting every proposal gives:
```console
$ wtm init --yes
✓ Project ready
Created .git/wtm/config.toml
base_path ../.trees
base_branch main
env_strategy example
Next steps
→ wtm create create a worktree to get started
→ wtm relocate adopt & align pre-existing worktrees
→ wtm run init configure per-worktree services
```
The config lives in `.git/wtm/`, so it is never committed and each clone has its own. `wtm config edit` opens it later; [Configuration](/0-28/guide/configuration/) covers every key.
## 3. Tell wtm how the app runs
[Section titled “3. Tell wtm how the app runs”](#3-tell-wtm-how-the-app-runs)
This step is optional: without it wtm manages worktrees and nothing else. With it, every worktree gets its own ports and can run its own dev servers.
```bash
wtm run init
```
The wizard detects the package scripts (and `docker compose` files), turns the ones you keep into jobs, finds the port each one reads in its `.env`, and offers to link the `.env` keys holding those ports so every worktree gets its own. On `acme`:
```console
$ wtm run init --yes --link-env
✓ Configured run module → .git/wtm/run.toml 2 added
✓ 2 port(s) declared in run.toml
✓ 3 .env value(s) now follow a port
```
It wrote two jobs, `api-dev` and `web-dev`, grouped in a default profile `all`. `wtm run list` shows them, and the [`run.toml` reference](/0-28/guide/run-toml/) explains the file.
## 4. Create a worktree
[Section titled “4. Create a worktree”](#4-create-a-worktree)
```bash
wtm create feat/login
```
The wizard asks the source branch, the env strategy and whether the new worktree is isolated (its own ports and data, the default), then shows a recap to confirm. The result:
```console
$ wtm create feat/login
✓ Created worktree feat/login
from main
env example · 3 ports settled (offset +10)
path ../.trees/feat-login
→ wtm go feat/login
```
"3 ports settled" means the `.env` files of the new worktree were copied from their templates and moved to its own ports. The first worktree gets `+10`, the next `+20`:
```console
$ cat ../.trees/feat-login/apps/web/.env
PORT=5183
API_URL=http://api-dev.feat-login.acme.localhost:11080
```
`API_URL` now points at this worktree's own API, under a name the run proxy serves (more on that in step 6).
## 5. Start its dev stack
[Section titled “5. Start its dev stack”](#5-start-its-dev-stack)
```bash
wtm go feat/login
wtm run up
```
`run up` first shows the worktrees it can act on, the current one already checked: press Enter. It then starts the default profile and opens the run view, one pane per job with its output live. Press `q` to leave it; the jobs keep running in the background. `-d` starts them and gives the prompt back at once:
```console
$ wtm run up -d
Profile all
› [1/2] api-dev
✓ api-dev started · http://api-dev.feat-login.acme.localhost:11080
› [2/2] web-dev
✓ web-dev started · http://web-dev.feat-login.acme.localhost:11080
Where to reach it
api-dev http://api-dev.feat-login.acme.localhost:11080
web-dev http://web-dev.feat-login.acme.localhost:11080
→ wtm run logs attach to the output
→ wtm run down stop the jobs
```
## 6. A second branch, side by side
[Section titled “6. A second branch, side by side”](#6-a-second-branch-side-by-side)
A bug report comes in while `feat/login` is running. No stash, no stopping anything:
```bash
wtm create fix/typo --yes
wtm run up fix/typo -d
```
The first time a second worktree starts while another one runs, wtm asks what to do about the first one: keep it running (parallel) or stop it (exclusive). It can remember the answer in `run.toml`. Keep it running, and both stacks run at once, each on its own ports and under its own name:
```console
$ wtm run ps
NAME KIND STATUS ADDRESS UPTIME WORKTREE
api-dev service running http://api-dev.feat-login.acme.localhost:11080 3s feat/login
web-dev service running http://web-dev.feat-login.acme.localhost:11080 3s feat/login
api-dev service running http://api-dev.fix-typo.acme.localhost:11080 2s fix/typo
web-dev service running http://web-dev.fix-typo.acme.localhost:11080 2s fix/typo
```
Open `http://web-dev.fix-typo.acme.localhost:11080` in a browser: each worktree has its own hostname, so the two apps do not share cookies either. The names answer while wtm runs the jobs; `wtm run url --raw` prints the plain `http://localhost:` address instead. On macOS, `wtm run proxy install` serves the names on port 80, which drops the `:11080`. See [Named URLs](/0-28/guide/addressing/).
`wtm list` shows every worktree and what it runs; `wtm ui` shows the same thing full screen, with PRs and logs:
```console
$ wtm list
main (parent) ● active ✓ clean
feat/login services ✓ clean
fix/typo services ✓ clean
```
## 7. Clean up
[Section titled “7. Clean up”](#7-clean-up)
The fix is merged. Remove its worktree and local branch; its jobs are stopped first:
```console
$ wtm clean fix/typo
✓ Stopped services on fix/typo
✓ Cleaned worktree and branch fix/typo
```
`clean` asks to confirm, and refuses a worktree with uncommitted or unpushed work unless you pass `--force`. Once several branches are merged, `wtm prune` removes all of their worktrees in one pass (with `gh`).
At the end of the day, stop what still runs:
```bash
wtm run down --all
```
## Next
[Section titled “Next”](#next)
* [Recipes](/0-28/guide/recipes/): a turbo monorepo, a docker compose app, a shared postgres, AI agents in parallel, stacked PRs.
* [Troubleshooting](/0-28/guide/troubleshooting/): what to do when a port is taken or a job crashes.
* The [user guide](/0-28/guide/) explains isolation, jobs and profiles, and the proxy in depth.
* `wtm --help` shows every flag, with examples; the [command reference](/0-28/reference/wtm/) is the same text.
# How wtm run works
Optional and opt-in: created by `wtm run init` (which detects docker-compose files and package scripts), not by the global `wtm init`. Declares dev **jobs** and groups them into **profiles**. Per-clone, never committed; share layouts with `wtm run export | wtm run import -`.
A job's `cmd` (and its `stop`) is a **`/bin/sh` line**, not a whitespace-split argv: quotes, `&&`, pipes, redirections and globs behave as they do in a terminal, and `${VAR}` expands from the job's environment. POSIX `sh` is used on every machine, never your own interactive shell, so a shared `run.toml` behaves the same everywhere.
Two worktrees can run their stacks side by side (each has its own ports and resource names), and `wtm run up feat-a feat-b` brings up as many as you name at once, each independent of the others. The first time `wtm run up` finds another worktree's jobs running it asks what to do about the machine's load, and can write the answer as `concurrency = "parallel" | "exclusive"` at the top of the file so it never asks again. `--parallel` and `--exclusive` override it for a single run; `--exclusive` is refused on several worktrees, since it stops all but one. `wtm run start` asks the same question and takes the same two flags. A worktree that shares its ports with one already running (a verbatim worktree and its source) is not a question of load: `run up` and `run start` offer to stop the other one or not to start, and refuse under `--yes` unless `--exclusive` was given.
```toml
[[job]]
name = "docker"
kind = "service" # long-running; with `stop` it's detached
cmd = "docker compose up -d"
stop = "docker compose down"
[job.ports] # host binding per worktree; template it as "${DB_PORT}:5432"
DB_PORT = 5432
[[job]]
name = "web"
kind = "service"
cmd = "pnpm dev"
[job.ports] # PORT=3000 on the main checkout, 3010 on the next worktree
PORT = 3000
[[job]]
name = "migrate"
kind = "task" # one-shot; blocks the profile, streams output, non-zero aborts
cmd = "pnpm migrate"
[[profile]]
name = "full"
jobs = ["docker", "web", "migrate"]
default = true
```
Jobs are scoped per worktree at runtime: starting `docker` from worktree A runs it with `cwd = A`; a separate process runs from worktree B. `wtm run down` only stops the current worktree's jobs unless you pass `--all`, which stops every worktree of this repository, never another one.
Every job (and every `on_create` / `on_clean` hook, under the condition given in [Project config](/0-28/guide/configuration/#project-config-configtoml)) also runs with the worktree's own identity in its environment, so two worktrees running the same services never share a resource:
| Variable | Value |
| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `WTM_BRANCH` | the branch, verbatim |
| `WTM_WORKTREE` | the branch as a slug safe for a Docker project, network or volume name |
| `WTM_ORDINAL` | the worktree's stable number. The main checkout is always `0`; every other worktree gets the smallest number free, kept for its whole life and released when it is cleaned |
| `WTM_PORT_OFFSET` | `WTM_ORDINAL` × the block (`port_offset_block`, 10 by default); the main checkout keeps the project's default ports, and so does a verbatim worktree |
| `WTM_ISOLATION` | `isolated` or `verbatim`, as chosen when the worktree was created |
| `COMPOSE_PROJECT_NAME` | `-`, derived from the worktree itself, never from the shell the command is typed in, which may belong to another worktree. The Docker daemon is machine-wide, so the repository qualifies the name: two clones both sitting on `main` do not share a stack. Not set for a verbatim worktree: its copied `.env` decides. The main checkout is named without its branch (its own `.env`'s value, else ``), so the shared services it hosts stay one stack whatever it has checked out |
`COMPOSE_PROJECT_NAME` is what keeps two worktrees' containers, networks and volumes apart. Nothing to declare: it works as soon as your jobs use `docker compose`. It reaches everything compose names for you, and nothing your file names itself: a `container_name`, or a volume's or network's explicit `name`, is resolved by the Docker daemon directly, so the second worktree to start meets the first one's. `wtm run init` finds those and offers to front them with the project; see [absolute names](#absolute-names) below.
**Ports** are declared per job, and wtm injects `base + WTM_PORT_OFFSET` under the name you chose; the command itself needs no arithmetic:
```console
$ wtm run job add web --cmd "pnpm dev" --port PORT=3000
$ wtm run up
✓ web started · PORT=3010
```
**wtm checks that the port was actually bound.** Declaring a port only injects a variable: nothing guarantees the command reads it. Once the jobs are up, `run up` dials each declared port and reports the ones nothing answers on:
```console
$ wtm run up
✓ web started · WEB_PORT=5183
Ports declared but not bound
web · nothing is listening on WEB_PORT=5183
but 5173 is listening — the base port
the command ran, but the variable did not reach it
```
The second line is the signature of a variable that never arrived: a CLI that only takes `--port`, a hard-coded port, a `.env` that wins, or a task runner filtering the environment. **Turborepo does this by default** (`envMode: "strict"`), so a root `turbo run dev` job needs `globalPassThroughEnv` in `turbo.json` for the ports to reach its packages. wtm never edits those files; it tells you what it observed.
It never fails the run, and a healthy stack costs nothing: the check stops as soon as every port answers. `--no-probe` skips it, and `port_probe_timeout` in run.toml sets the budget (default 15s, a negative value turns it off). `probe = false` on a job skips it for that job, and `binds_no_port = true` marks a service that listens on nothing by design (a watcher, a worker), so it is no longer offered a port.
A declaration overrides whatever the environment already sets for that variable, and the job's `stop` command runs with the same ports its `cmd` did. For Docker, template the host side of the mapping (`"${DB_PORT}:5432"`) and declare `DB_PORT = 5432`: the container port never moves, only the binding does.
The `on_create` / `on_clean` hooks get those ports too, so the `docker compose down` of an `on_clean` reads the same `${DB_PORT}` its `up` bound. A hook is not a job, though, so it only gets the names a **single** job declares: if `web` and `api` both declare `PORT` on different bases, `PORT` has no answer outside a job and is left unset rather than resolved to one of the two.
`wtm run init` composes a configuration you can start, not an inventory of the repo. It proposes everything it finds and **checks the fewest things**: only scripts whose name contains `dev`, and not a root `dev` a workspace package also declares. That one is an orchestrator (`turbo run dev`, `pnpm -r dev`) and running it beside the packages it fans out to would start each of them twice on the same ports. Nothing unchecked is written.
It asks which of them starts the others: the relation is declared, never inferred from a command. A root can name another root, so `dev` → `dev:shop` → the shop apps is written one row at a time and what the top one holds is read through the whole chain; two roots may also name the same app. Only a cycle is refused.
It also asks which jobs should answer under their own name, and proposes every service that declares the port it listens on: `PORT`, or `_PORT` for the ones after the first. A port a job only dials (`DB_PORT`, `REDIS_PORT`) is never proposed: a name nothing answers under is worse than no name at all. Unchecking a job withdraws the `url` it already had.
It then walks you through the ports detection pre-filled, and the **profiles** `wtm run up` will offer: one per package, plus one gathering everything, which you rename, merge or drop. Jobs at the repository root (a compose stack) join every profile, so starting one package alone still brings its infrastructure up. Tasks are placed ahead of the services that depend on them, so a profile brings the database up to date before starting what reads it. In a single-package repo (or past a handful of packages, where a profile each stops being something you can read), the split collapses to one profile.
A service detection found no port for is reported rather than asked about: inventing one would move the guess onto you, and `wtm run up` will say the port was never bound anyway.
`wtm run init` writes those Docker declarations for you. It reads the `ports:` of the compose files you pick: a mapping that already reads a variable is declared as-is, while a literal `"5432:5432"` would bind the same port in every worktree and is therefore **not** declared; wtm shows the line to write instead. Pass `--patch-compose` and it makes the change itself:
```diff
postgres:
ports:
- "5432:5432"
- "${POSTGRES_PORT:-5432}:5432"
```
Only the port value is rewritten, at its exact position (comments, indentation and quoting style are untouched), and the `:-5432` default keeps `docker compose up` working on its own, with no dependency on wtm. Re-running `run init` backfills a compose job that predates declarative ports without overwriting one you set by hand.
Dev servers are pre-filled the same way, from the env files next to their `package.json`: a `PORT` or `*_PORT` entry in `.env.local`, `.env`, or a committed `.env.example`. Each job takes the file in its own directory, so in a monorepo every package keeps its own port. wtm declares the port and never rewrites a command. Where the job *reads* that port is a question the wizard puts, job by job: from its own `.env` (wtm writes `KEY=` there and in the committed template, and your config reads it: `server.port: Number(process.env.VITE_PORT)`), or from the command, `--cmd 'pnpm dev --port ${PORT}'`. The `.env` route is the pre-filled answer because it is the only one that still holds when you start the app yourself; the command route isolates what `wtm run` starts and nothing else, which the final report says in as many words. `--write-port-keys` takes the first route for every job without asking. The port it declares is also the base the `[[env_port]]` links below follow, so a `.env` holding both `PORT=5173` and a `VITE_API_URL` pointing at it ends up with the two shifted together.
[]()**Absolute names.** A compose file that pins its own names bypasses the project prefix, which is what makes a second worktree fail outright: Docker refuses a duplicate `container_name`, and a volume or network pinned by `name` is silently shared instead. `run init` reports them, and `--patch-compose` fronts each with the project:
```diff
postgres:
container_name: myapp-postgres
container_name: "${COMPOSE_PROJECT_NAME:-myapp}-postgres"
```
The `:-myapp` default reproduces the name the file used to pin, so `docker compose up` on its own is unchanged. Ports and names are one question, not two: accepting half of them still leaves two worktrees unable to run at once. A `name` under `external: true` is left alone (sharing it is the declaration's whole point), as is one that already reads a variable, and a volume declared as a bare key was never affected: compose already prefixes it with the project. **A volume that pinned its `name` gains one per worktree, each starting empty**: the data already written stays under the old name, and moving it across is yours to do.
wtm declares only what it can actually isolate, and says why for the rest: a port range, a mapping with no host port, a `ports:` list carrying a YAML **anchor or alias** (rewriting it would move every service sharing it), a `${DB_PORT}` with no default (the file never says which port it stands for), and a variable two services declare with two different defaults. It also withdraws a detected port rather than write a `run.toml` its own loader would refuse (two bases a multiple of the block apart), naming both sides.
A server that **ignores** `PORT` and only takes its port as a CLI flag (Vite is the usual one) reads it back from the same variable, because `cmd` is a shell line:
```console
$ wtm run job add web --cmd 'pnpm dev --port ${PORT}' --port PORT=3000
```
## Ports hard-coded in a `.env`
[Section titled “Ports hard-coded in a .env”](#ports-hard-coded-in-a-env)
Shifting a service's host port only helps if whatever connects to it follows. That is easy when the consumer reads `${DB_PORT}`, but in most projects the port is not in a variable of its own, it is **buried in a URL**: `DATABASE_URL=postgres://u:pw@localhost:5432/app`, `API_URL=http://localhost:3000/api`. An app running on the host, outside Docker, then talks to the wrong worktree.
An `[[env_port]]` link says which key carries which port:
```toml
[[env_port]]
file = ".env"
key = "DATABASE_URL"
job = "docker" # required: the job declaring the port
port = "DB_PORT" # a port that job declares
```
The link names the key, never a position. wtm looks for the **declared base** inside the value and shifts only that number, leaving credentials, host, path and query exactly as they were:
```diff
DATABASE_URL=postgres://u:pw@localhost:5432/app
DATABASE_URL=postgres://u:pw@localhost:5442/app
```
`wtm run init` scans your configured `.env` targets and offers the keys whose value holds a declared base; `--link-env` writes them without asking. Nothing is ever inferred without one or the other. The rewrite then happens when an **isolated** worktree is created (never for a verbatim one, whose `.env` is kept as copied) and whenever `wtm env` reconciles, whose recap offers "Apply, and keep this worktree's .env verbatim from now on" beside the plain apply, so neither command imposes the pass (`--check` reports without writing, and counts a pending shift as drift). `wtm env --mode refresh` compares linked values **modulo the offset**, so a worktree holding `5442` against a `main` holding `5432` is not a conflict; a real difference in the same value still is.
wtm reports rather than guesses when it cannot be sure: the key is missing, the base appears more than once in the value, or neither the base nor any offset of it is there. Rewriting on a guess could corrupt a URL, so those lines are named and left alone.
A value that is not a port, such as which database or which realm a worktree holds in a [shared service](/0-28/guide/shared-services/), is written whole by an `[[env]]` link, from a template over `{namespace}`, `{port.NAME}`, `{origin}`, `{worktree}` and `{ordinal}`:
```toml
[[env]]
file = "apps/api/.env"
key = "DATABASE_URL"
job = "postgres"
value = "postgresql://app:app@localhost:{port.POSTGRES_PORT}/{namespace}"
```
A key is written by an `[[env]]` link or an `[[env_port]]` link, never both.
## Values that carry an address, not a port
[Section titled “Values that carry an address, not a port”](#values-that-carry-an-address-not-a-port)
A port in a `.env` is enough for one app talking to itself. It is not enough the moment a front end calls a separate API: the browser is on the worktree's **name**, so the `Origin` it sends is a name, and a `CORS_ORIGIN` holding `http://localhost:5183` blocks it. Ports and named URLs cannot both be half-true in the same file.
So a link writes the job's **whole origin** rather than its port number, whenever two things hold at once: the job it names **publishes a url** for that very port, and the value **has the shape of a URL**:
```diff
VITE_API_URL=http://localhost:4001
VITE_API_URL=http://api-dev.feat-x.monorepo.localhost
CORS_ORIGIN=http://localhost:5173
CORS_ORIGIN=http://web-dev.feat-x.monorepo.localhost
PORT=4011 # a bare number stays a number
DATABASE_URL=postgres://u:pw@localhost:5442/app # Postgres has no name, and never will
```
Both conditions matter. The first leaves Postgres alone: the proxy only speaks HTTP. The second leaves the binding keys alone: `PORT` belongs to a job that *does* publish a name, and must still be a number. Without the redirection installed the address carries the proxy's port (`…localhost:11080`), which changes nothing for CORS and nothing for cookie isolation: a port is part of an origin, but never part of a *cookie's* origin.
This is `addressing = "names"`, the default when `run.toml` does not say. `wtm run addressing ports` keeps port numbers everywhere, and `wtm run addressing names` goes back: it writes `addressing` in `run.toml`, then offers to settle the worktrees whose `.env` spells the other one. It is a real inverse, and `--keep-env` switches the setting alone. The main checkout follows the rule below: the switch brings it back to ports, and never moves it onto names. On a machine where the proxy is off, ports are written whatever the project asked for, and a notice says so. Under `names` the named URL becomes the only working entrance: opening `localhost:5183` directly sends an `Origin` the API no longer knows. `wtm run url` and `wtm run open` hand out the right link.
The rewrite happens where wtm provisions: a worktree, when it is created and whenever `wtm env` reconciles it. **Nothing moves the main checkout's `.env` onto names unless you name it**: it is the one checkout that exists without wtm, the one a colleague clones and a `docker compose up` reads. So under `names` its values still hold ports, and then **the working entrance is the port**, not the name: the browser on `localhost:5175` sends an `Origin` the API's `CORS_ORIGIN` recognises, while the named URL sends one it does not. wtm still hands out the name everywhere (`run up`, `run url`, `run open`, the run view, the `wtm ui` panel) and adds one line saying the `.env` is out of step and which command aligns it (`--raw` gives the port URL). The route is registered either way, so nothing has to restart:
```bash
wtm env main # the positional takes the main checkout like any other worktree
```
wtm only ever sees the keys declared as `[[env_port]]` links: a `CORS_ORIGIN` nothing links to a declared port is invisible to both the pass and the warning, so silence means "nothing linked is out of step", not "everything is right". And a `.env` that already holds named origins whose port went stale (what `wtm run proxy install` does to every worktree at once) keeps its names and is told they are out of step, rather than being sent back to ports.
Doing it is a choice, not a formality. Main then stops behaving as a checkout without wtm: whoever reads that `.env`, or starts the stack from it, depends on the proxy being up. Two moments make it worth doing: right after switching a project to `names`, and after `wtm run proxy install`, which drops the `:11080` from the origins already written. Going back is `wtm run addressing ports` then `wtm run addressing names`: the first brings main back to ports with every other worktree, the second moves the others onto names again and leaves main where it is: a pass over every worktree may return main to ports, only `wtm env main` takes it to names. And `wtm create` from main is unaffected either way: a copied value carrying main's segment is recognised and rewound to the new worktree's.
Two base ports must not differ by a **multiple of the block**, or two worktrees end up on the same one: `3000` and `3010` are refused when `run.toml` is read, naming both sides, which is the last moment the problem is still explainable. Neighbouring ports are fine: a uniform offset preserves the gaps, so `5434`/`5435`/`5436` become `5444`/`5445`/`5446` on the next worktree. Set `port_offset_block` at the top of `run.toml` when a project's ports genuinely need more room than 10.
> **Upgrading:** jobs used to run with no `COMPOSE_PROJECT_NAME`, so `docker compose` named the project after the working directory. Stacks started before this version are under the old name and a new `run up` will not find them; stop them once with `docker compose -p down`.
>
> The run daemon is global and outlives the command that started it, so a daemon started by an older binary would keep serving its own behavior. It is now refused rather than silently used: any `run` command names both versions and points at `wtm run daemon restart`, which hands the jobs over. Detached services survive that restart; foreground ones are stopped.
`run up` and `run start` **attach**: a full-screen view opens with one pane per job, and `wtm run logs` reopens it later. Leaving the view (`q`, or Ctrl+C outside focus mode) detaches: the daemon keeps the jobs running. `-d` starts them and hands the prompt back instead. Unless both stdin and stdout are a terminal (`wtm run up > run.log` included), or under `--output json`, no view opens: the run reports itself as lines, which is what a script or an agent gets. Each job's output is also journaled to `/wtm/logs//.log` (5 MB x 3 within one run), and `run logs` reads that back for a job that is no longer running. Starting a job clears its log first, so one file is one run: what `run logs` replays never reaches back into an earlier one.
The daemon itself is disposable. It exits \~30 s after the last **foreground** job, while detached services (those with a `stop` command, a `docker compose up -d` typically) keep running without it: the real work belongs to Docker, not to wtm. What wtm keeps is an index of what it started, `jobs.json` next to the [global config](/0-28/guide/configuration/#global-config), which the next daemon reads back. That is what makes `wtm run ps` still list your stacks after a reboot, and `wtm run down` still stop them. Those stacks show as `detached` rather than `running`, because nothing about them was ever verified: wtm launched them and has not seen them since. The other statuses `run ps` shows are `running`, `joined` (a worktree's hold on a shared service), `stopped`, `crashed` and `reaped`; see [`run ps` statuses](/0-28/guide/jobs-and-profiles/#run-ps-statuses). `wtm run daemon status` reports what is up, and `stop` / `restart` are the way out when you want the process gone.
# Isolation: isolated or verbatim
Two worktrees of the same repository run the same services. Isolation is what keeps them from colliding on a port, a Docker container or a database, and wtm lets each worktree decide, once, whether it wants that.
## The two answers
[Section titled “The two answers”](#the-two-answers)
`create`, `extract` and `checkout` record how the new worktree stands against its **source** (the worktree or branch it was created from), in the worktree's `meta.json`:
* **Isolated** (the default). The worktree gets its own ports (`base + WTM_PORT_OFFSET`), its own compose project (`COMPOSE_PROJECT_NAME=-`, so its own containers, networks and volumes) and its own namespace in each shared service. These values are written into its `.env` files when it is created, and applied when `wtm run` starts its jobs, so a `docker compose up` or a `pnpm dev` typed by hand is isolated too.
* **Verbatim**. The `.env` is kept as it was copied, and `wtm run` runs the worktree on the ports and the data that file names: its source's. `COMPOSE_PROJECT_NAME` is not set by wtm: the copied `.env` decides, so the worktree **shares its source's compose volumes and data**. Only one of the two can be up at a time: `run up` and `run start` say so and offer to stop the other one, rather than letting a port bind fail.
The question is only asked when `run.toml` declares something a worktree could isolate: a port, a namespace, an `[[env_port]]` or `[[env]]` link, a compose stack. Without any, both answers do the same thing.
Pick per worktree with `--isolation isolated|verbatim`; set the project's default with `isolation = "verbatim"` at the top of `run.toml`. Under `--yes`, a creation takes the flag, else the project default, else `isolated`.
## Changing your mind
[Section titled “Changing your mind”](#changing-your-mind)
`wtm env --isolation isolated|verbatim` settles an existing worktree on the other answer:
* `--isolation isolated` writes every port, compose project and namespace value the worktree was left without.
* `--isolation verbatim` puts the values wtm owns (linked ports, `[[env]]` values and `COMPOSE_PROJECT_NAME`) back to the source's, removes the ones the source lacks, and leaves every other key alone. The worktree then shares its source's compose volumes again. Namespaces the worktree already created stay recorded, so `wtm clean` still drops them.
The new isolation is recorded only once the `.env` is in line with it: a run that fails or is cancelled records nothing. The interactive `wtm env` shows the values it will put back before it does, and its recap can keep a worktree verbatim from then on.
## Worktrees created before v0.28
[Section titled “Worktrees created before v0.28”](#worktrees-created-before-v028)
A worktree created by an earlier wtm has no `isolation` in its `meta.json`. It keeps running on its source's ports and compose project until you decide:
* `wtm env --yes` reconciles its keys and **touches nothing run-related**: no port shift, no `COMPOSE_PROJECT_NAME`. The report says the adoption is pending.
* The interactive `wtm env ` offers to adopt isolation, naming what changes: a new compose project, so the volumes it uses today (`_*`) are no longer used.
* `wtm env --isolation isolated` adopts it explicitly; `--isolation verbatim` records that it stays on its source's values.
Until one of these runs, `wtm run up` and `wtm run start` refuse the worktree and name the command to run.
## Hooks
[Section titled “Hooks”](#hooks)
`on_create` and `on_clean` hooks get the worktree's run variables (`COMPOSE_PROJECT_NAME`, `WTM_*` and the declared ports) **only when** `run.toml` declares a job running `docker compose` **and** the worktree recorded its isolation. Otherwise a hook runs with the environment it had before the run module existed. When the worktree's own `.env` sets `COMPOSE_PROJECT_NAME`, that value is the one the hook gets.
## Foreign data and `touches`
[Section titled “Foreign data and touches”](#foreign-data-and-touches)
Some tasks change data: a migration, a reset, a seed. Run against data the worktree does not own, they change it for someone else too. wtm calls that **foreign data**:
* a verbatim worktree's source's data (the two share a database);
* a shared service's data when it declares no `[job.namespace]` (every worktree shares it).
wtm cannot read that from a command, so a job declares it: `touches = ["postgres"]` names the services whose data it changes. `wtm run init` asks it task by task and pre-fills what the names make obvious; `wtm run job add|edit --touches` sets it by hand.
Before starting a job whose `touches` reach foreign data (including a job started by a runner through `runs`), `run up` and `run start` stop and ask. Under `--yes` they refuse, and name the way out: `--force` runs it anyway; `wtm env --isolation isolated` gives a verbatim worktree its own data; a `[job.namespace]` gives each worktree its own part of a shared service.
# Jobs, profiles and runners
## Jobs
[Section titled “Jobs”](#jobs)
A **job** is the unit wtm runs, declared as a `[[job]]` in `run.toml`. Its `kind` is one of two:
* a **service** is long-running: a dev server, a docker stack. Without a `stop` command wtm tracks its process and stops it with SIGTERM. With one, its `cmd` is a **launcher** (`docker compose up -d`): wtm waits for it to exit, then considers the real work owned by something else (Docker) and runs `stop` to bring it down.
* a **task** is one-shot: a migration, a seed. It runs to the end, its output streams live, and a non-zero exit aborts the profile it belongs to.
`cmd` and `stop` are `/bin/sh` lines: quotes, `&&`, pipes and `${VAR}` behave as in a terminal. A job runs in the worktree it was started for (plus its `cwd`), so the same job runs once per worktree, unless it is a [shared service](/0-28/guide/shared-services/).
Declare jobs with `wtm run init` (detected from compose files and package scripts) or `wtm run job add`; change them with `wtm run job edit`, which also accepts every field as a flag.
## Profiles
[Section titled “Profiles”](#profiles)
A **profile** is a named, ordered group of jobs: `[[profile]] name = "shop", jobs = ["db", "migrate", "shop-api"]`. Order matters: a task placed before a service runs to the end before the service starts.
`wtm run up` starts **exactly one profile**: `--profile`, else the profile marked `default = true`, else the only one declared. With several profiles and no default, an interactive run asks which (the cursor on the default); `--yes` and runs without a terminal fail naming `--profile`. A `run.toml` declaring no profile at all starts every job.
`wtm run start --job ` starts a single job outside any profile. `wtm run down` stops what a worktree runs (`--profile` narrows it to one profile, `--all` covers every worktree of the repository); `wtm run stop --job ` stops one job.
## Runners
[Section titled “Runners”](#runners)
In a monorepo, one root script often starts several apps at once: `turbo run dev`, `pnpm -r dev`, a compose file with several services. Declare that relation on the runner: `runs = ["shop-web", "shop-api"]`. wtm never infers it from the command.
It is what lets the runner carry its children's ports and named URLs, and what keeps wtm from starting an app twice, once by the runner and once on its own. While the runner is up, its children have no row of their own: their addresses are reported under the runner.
## The run view, or `-d`
[Section titled “The run view, or -d”](#the-run-view-or--d)
`run up`, `run start` on a service and `run logs` open the **run view**: a full-screen view with one pane per job. Leaving it (`q`, or Ctrl+C outside focus mode) **detaches** (the jobs keep running in the background daemon), and `wtm run logs` reopens it later.
`-d` starts the jobs and gives the prompt back instead. No view ever opens unless both stdin and stdout are a terminal, nor under `--output json`: the run then reports itself as lines, which is what a script or an agent gets. A task always runs inline, with or without `-d`.
Each job's output is also written to `/wtm/logs//.log`, cleared when the job starts, so `run logs` can replay a job that is no longer running.
## Checking the ports
[Section titled “Checking the ports”](#checking-the-ports)
Declaring a port only injects a variable. Once the jobs are up, `run up` and `run start` dial each declared port and report the ones nothing answers on, the sign of a command that never read its variable. The check never fails the run and stops as soon as every port answers.
* `--no-probe` skips it for one run; `probe = false` on a job skips it for that job (an interactive run offers to write it for a warning that comes back every time).
* `port_probe_timeout` at the top of `run.toml` sets the budget in seconds (15 by default; a negative value turns the check off).
* `binds_no_port = true` says a service listens on nothing by design (a watcher, a worker, a runner whose children hold the ports), so wtm stops offering it a port.
## Several worktrees at once
[Section titled “Several worktrees at once”](#several-worktrees-at-once)
The first time `run up` or `run start` finds jobs running in another worktree, it asks what to do about the machine's load, and can remember the answer as `concurrency = "parallel" | "exclusive"` in `run.toml`. `--parallel` and `--exclusive` answer for one run. Worktrees that share their ports (a [verbatim](/0-28/guide/isolation/) worktree and its source) cannot run together whatever the setting: wtm offers to stop the other one.
## `run ps` statuses
[Section titled “run ps statuses”](#run-ps-statuses)
`wtm run ps` lists what the daemon holds across every repository, from anywhere. A runner binds no port, so its ADDRESS is empty: the apps it started are listed under its row with their addresses (`held` in the JSON). A job's `status` is one of:
| Status | Meaning |
| ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `running` | a process wtm started and still watches |
| `detached` | a service whose launcher exited, leaving the work to something wtm does not own (a compose stack); nothing about it is verified, and it survives the daemon |
| `joined` | this worktree's hold on a [shared service](/0-28/guide/shared-services/) running in the main checkout; it owns no process |
| `stopped` | stopped on request |
| `crashed` | a service that exited without being asked to (`exit_code` in the JSON says how) |
| `reaped` | a service that outlived the daemon which owned it, taken down by the next one |
The results of `run up`, `run down` and `run stop` use their own vocabulary: `started`, `joined`, `done` (a task that ran to the end), `stopped`, `released` (a shared service this worktree let go of, still up for others), `not_running` (nothing was up under that name) and `error`.
# Migrating to 0.28
0.28 turns the `run` module into a per-worktree dev stack and settles its interface. Most of what changed only concerns you if you used `wtm run` or `wtm switch` in 0.27, or script wtm. This page lists what to do, then every breaking change in detail.
## Checklist
[Section titled “Checklist”](#checklist)
1. **Open a new shell** after upgrading (or re-run `eval "$(wtm shell-init)"`). The `wtm` shell function now returns the command's exit code; the old one always returned `0`.
2. **Decide the isolation of each worktree created with 0.27**: see [below](#worktrees-created-with-027).
3. **Stop stacks started by 0.27** once, with `docker compose -p down`: they ran under another compose project name, and a new `run up` will not find them.
4. **Re-read your hooks**: they now run through `/bin/sh -c`.
5. **Update scripts and agents**: `--non-interactive` → `--yes`, `wtm switch` → `wtm go` + `wtm run up`, the new [JSON contract](#the-json-contract-of-wtm-run) and [exit codes](#exit-codes). Re-run `wtm agents install` so your agent's skill describes 0.28.
## Worktrees created with 0.27
[Section titled “Worktrees created with 0.27”](#worktrees-created-with-027)
A worktree created before 0.28 recorded no isolation (its `meta.json` has no `isolation` field). It keeps running on its source's ports and compose project:
* `wtm env --yes` reconciles its `.env` keys and **touches nothing run-related** (no port shift, no `COMPOSE_PROJECT_NAME`) and says so;
* `wtm run up` and `wtm run start` refuse it, naming the command to run.
Decide once per worktree:
* `wtm env --isolation isolated` adopts isolation: its own ports and a new compose project. Its current volumes (`_*`) are no longer used.
* `wtm env --isolation verbatim` keeps it on its source's values.
The interactive `wtm env` offers the same choice and says what it changes. See [Isolation](/0-28/guide/isolation/).
## Hooks
[Section titled “Hooks”](#hooks)
* **Hooks run through `/bin/sh -c`.** They used to be split on spaces and run without a shell: `&&`, pipes, redirections, quotes and `$VAR` now behave as in a terminal. A shell character that was passed literally (`$`, `*`, `;`, `&`, quotes) now means something.
* **Placeholders are quoted for you.** `{{worktree}}`, `{{branch}}`, `{{root}}` and `{{from_branch}}` are quoted for the spot they land in, so a path holding `'` or `$` arrives intact: write `cd {{worktree}}`, without quotes of your own.
* **Run variables reach hooks under two conditions.** `COMPOSE_PROJECT_NAME`, `WTM_*` and the declared ports are passed to a hook only when `run.toml` declares a `docker compose` job **and** the worktree recorded its isolation. Otherwise a hook gets the environment it had in 0.27. A `COMPOSE_PROJECT_NAME` set by the worktree's own `.env` always wins.
## Commands
[Section titled “Commands”](#commands)
| 0.27 | 0.28 |
| ------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `wtm switch ` | `wtm go ` then `wtm run up` |
| `--non-interactive` (`init`, `run init`) | `--yes` / `-y`, now fully unattended, global config included |
| `init --only --yes` skipped the confirmation | it regenerates the section without the wizard |
| `run up `, `run start ` … | the worktree is the positional (a branch name, never a path), the job or profile a flag: `run up [worktree...] --profile `, `run start [worktree] --job ` |
| `run up --profile a --profile b` | one profile per run; with several and no default, `--yes` fails naming `--profile`. A repeated single-value flag is refused |
| `run down --all` stopped every repository's jobs | it stops every worktree of the current repository only |
| `run import` merged into `run.toml` | it replaces the file; `--replace` and its `--force` are gone, and without a terminal it needs `--yes` |
| `run ps` offered an action picker | it lists; `wtm run logs` opens the view that acts on jobs |
Two refusals are new. When `run.toml` declares a job, a worktree whose derived name another live worktree already carries (`feat.x` beside `feat/x`: same compose project, same namespaces, same host) is refused at creation, exit `10`. And `wtm relocate` no longer moves a worktree whose jobs are running (`blocked_jobs`): `wtm run down ` first.
`run.toml` is also validated more strictly: a job or profile name with a space, a `kind` other than `service` / `task`, two port bases a multiple of `port_offset_block` apart, a reference to an undeclared job, or a link on a `.env` file `config.toml` does not provision are refused. A refused file never fails a core command; it only disables the run part, with a warning.
## The JSON contract of `wtm run`
[Section titled “The JSON contract of wtm run”](#the-json-contract-of-wtm-run)
* A job object is keyed `name`; any other object pointing at a job says `job`.
* A worktree is always `branch` + `path` (no more `worktree` or `work_dir`).
* `run up`, `run down`, `run stop` and `run logs` always return an **array of per-worktree documents**, however many worktrees there are. `run stop` is no longer a single object; `run logs` is `[{branch, path, lines: [{job, at, text}]}]`.
* `run ps` returns `branch`, `path` and `project`, `held` for a runner, and no longer `released`.
* `run addressing` returns `settled`, `pending` and `main_left` as `{branch, path}` objects.
* Result statuses are `started`, `joined`, `done`, `stopped`, `released`, `not_running`, `already_running` and `error`. `stopped` is no longer reported for something that was not running. The shared-service status `attached` is now `joined` (an older daemon's `attached` is still read).
* A command with per-job results writes its whole document, then exits non-zero; one that fails before writes nothing on stdout.
## Exit codes
[Section titled “Exit codes”](#exit-codes)
* **2**: a usage error: unknown flag or subcommand (including `wtm run `), unreadable flag value, unknown `--output` format, extra argument. It was `1`.
* **14**: a job or profile `run.toml` does not declare, now also for `run job|profile edit|rm` and `run export --profile`. It is checked before the daemon is contacted: nothing was started or stopped.
* **16**: no `run.toml`; the message says to run `wtm run init`.
## The daemon
[Section titled “The daemon”](#the-daemon)
A 0.27 daemon still running is replaced automatically when it holds no job. When it holds some, `run up` refuses and names `wtm run daemon restart`, which replaces it: detached services survive the restart, foreground ones are stopped.
## For pre-release testers
[Section titled “For pre-release testers”](#for-pre-release-testers)
Since the last `0.28.0-beta`: `attached` → `joined`; `--profile` takes one value; `run export` accepts `--output`; `run start` gains `--exclusive`, `--parallel` and `--no-probe`; no view opens unless stdin **and** stdout are terminals (`run up > run.log` no longer opens it); `Esc` in `run init` prints `= Aborted.` and exits with the abort code.
# Recipes
Complete setups for common projects. Each one shows the `run.toml` it ends with (in `.git/wtm/run.toml`) and the commands that use it. `wtm run init` writes most of this from detection; the recipes show where to take it by hand, with `wtm run job add|edit` or an editor. Every key is described in the [`run.toml` reference](/0-28/guide/run-toml/).
* [A pnpm or turbo monorepo](#a-pnpm-or-turbo-monorepo)
* [A docker compose app](#a-docker-compose-app)
* [One postgres, a database per worktree](#one-postgres-a-database-per-worktree)
* [Several AI agents, each in its own worktree](#several-ai-agents-each-in-its-own-worktree)
* [Stacked pull requests](#stacked-pull-requests)
## A pnpm or turbo monorepo
[Section titled “A pnpm or turbo monorepo”](#a-pnpm-or-turbo-monorepo)
One root script (`turbo run dev`, `pnpm -r --parallel run dev`) starts every app. Declare each app as a job with its own port, then the root script as a **runner** that `runs` them:
```toml
[[job]]
name = "web"
kind = "service"
cmd = "pnpm dev"
cwd = "apps/web"
[job.ports]
WEB_PORT = 3000
[job.url]
port = "WEB_PORT"
[[job]]
name = "api"
kind = "service"
cmd = "pnpm dev"
cwd = "apps/api"
[job.ports]
API_PORT = 4000
[job.url]
port = "API_PORT"
[[job]]
name = "dev"
kind = "service"
cmd = "pnpm turbo run dev"
runs = ["web", "api"]
[[profile]]
name = "dev"
jobs = ["dev"]
default = true
```
* The runner is started with its children's ports in its environment (`WEB_PORT=3010`, `API_PORT=4010` in the first worktree), and their named URLs are published under it. `run ps` lists the runner with the apps it holds beneath it.
* **Give each app its own variable name.** A runner passes one environment to every child, so two apps both reading `PORT` cannot get two values. Each app reads its own: `"dev": "next dev --port ${WEB_PORT:-3000}"` in `apps/web/package.json`.
* **Turborepo filters the environment by default** (`envMode: "strict"`), so the ports never reach the apps. Let them through in `turbo.json`: `"globalPassThroughEnv": ["WEB_PORT", "API_PORT"]`.
* `web` and `api` stay startable on their own: `wtm run start --job api` starts one app without the runner. wtm refuses to start an app its running runner already holds.
`wtm run init` asks, for each root script, which declared jobs it runs. By hand:
```bash
wtm run job add dev --cmd 'pnpm turbo run dev' --runs web --runs api --yes
wtm run profile add dev --jobs dev --default --yes
```
## A docker compose app
[Section titled “A docker compose app”](#a-docker-compose-app)
A compose file whose services each worktree runs on its own. wtm sets `COMPOSE_PROJECT_NAME` per worktree (`acme-feat-login`), so containers, networks and volumes are already separate. What is left is the host ports, which must read a variable:
docker-compose.yml
```yaml
services:
db:
image: postgres:16
ports:
- "${DB_PORT:-5432}:5432"
redis:
image: redis:7
ports:
- "${REDIS_PORT:-6379}:6379"
```
```toml
[[job]]
name = "stack"
kind = "service"
cmd = "docker compose up -d"
stop = "docker compose down"
[job.ports]
DB_PORT = 5432
REDIS_PORT = 6379
[[job]]
name = "api"
kind = "service"
cmd = "pnpm dev"
cwd = "apps/api"
[job.ports]
PORT = 4000
[job.url]
port = "PORT"
[[profile]]
name = "dev"
jobs = ["stack", "api"]
default = true
[[env_port]]
file = "apps/api/.env"
key = "DATABASE_URL"
job = "stack"
port = "DB_PORT"
```
* With a `stop` command, `cmd` is a launcher: wtm waits for `docker compose up -d` to exit and runs `docker compose down` on `wtm run down`. `run ps` shows the stack as `detached`.
* The `[[env_port]]` link rewrites the port inside `DATABASE_URL` (`postgresql://app:app@localhost:5432/app` becomes `…:5442/app` in the first worktree) when the worktree is created, and whenever `wtm env` reconciles it.
* `wtm run init` finds literal host ports (`"5432:5432"`) and absolute names (`container_name`, a volume's `name`) and offers to rewrite them; `--patch-compose` does it unattended. A renamed volume starts empty.
* A `docker compose up` typed by hand in the worktree is isolated too, since the `.env` carries the ports and `COMPOSE_PROJECT_NAME`.
See [How `wtm run` works](/0-28/guide/how-run-works/) for compose names and the port check.
## One postgres, a database per worktree
[Section titled “One postgres, a database per worktree”](#one-postgres-a-database-per-worktree)
Ten worktrees do not need ten postgres containers. A **shared** service runs once, in the main checkout, and each worktree gets its own database in it, created on start and dropped on `wtm clean`:
```toml
[[job]]
name = "postgres"
kind = "service"
cmd = "docker compose up -d postgres"
stop = "docker compose stop postgres"
scope = "shared"
[job.ports]
POSTGRES_PORT = 5432
[job.namespace]
name = "app_{worktree}"
create = "scripts/db-worktree-add.sh"
remove = "scripts/db-worktree-drop.sh"
[job.namespace.env]
PGPASSWORD = "postgres"
[[job]]
name = "migrate"
kind = "task"
cmd = "pnpm db:migrate"
cwd = "apps/api"
touches = ["postgres"]
[[job]]
name = "api"
kind = "service"
cmd = "pnpm dev"
cwd = "apps/api"
[job.ports]
PORT = 4000
[job.url]
port = "PORT"
[[profile]]
name = "dev"
jobs = ["postgres", "migrate", "api"]
default = true
[[env]]
file = "apps/api/.env"
key = "DATABASE_URL"
job = "postgres"
value = "postgresql://postgres:postgres@localhost:{port.POSTGRES_PORT}/{namespace}"
```
The two commands are yours; wtm runs them with `$WTM_NAMESPACE` (`app_feat-login`), the job's ports and the `namespace.env` variables. `create` runs on **every** start, so it must do nothing when the database exists:
scripts/db-worktree-add.sh
```sh
#!/bin/sh
set -e
psql="psql -h localhost -p $POSTGRES_PORT -U postgres -v ON_ERROR_STOP=1"
exists=$($psql -tAc "SELECT 1 FROM pg_database WHERE datname = '$WTM_NAMESPACE'")
[ "$exists" = 1 ] && exit 0
$psql -c "CREATE DATABASE \"$WTM_NAMESPACE\" TEMPLATE app"
```
scripts/db-worktree-drop.sh
```sh
#!/bin/sh
set -e
psql -h localhost -p "$POSTGRES_PORT" -U postgres -v ON_ERROR_STOP=1 \
-c "DROP DATABASE IF EXISTS \"$WTM_NAMESPACE\" WITH (FORCE)"
```
* `TEMPLATE app` starts each worktree from a copy of main's data (`app`, the database main's `.env` names), so there is nothing to seed. It refuses while main has open connections; drop the `TEMPLATE` clause for an empty database.
* The `[[env]]` link writes the whole `DATABASE_URL`, pointing each worktree at its own database. `migrate` declares `touches = ["postgres"]`; since every worktree has its own namespace, it runs without a question.
* `run ps` shows the worktrees holding the service as `joined`; it stops once none holds it.
* `wtm clean feat/login` drops the database after removing the worktree; `--keep-data` keeps it. When postgres is down, `--yes` defers the drop to its next start and `--drop-data` starts it to drop now.
The same fields exist as flags: `wtm run job add postgres --scope shared --namespace-name 'app_{worktree}' --namespace-create … --namespace-remove … --namespace-env PGPASSWORD=postgres`. See [Shared services](/0-28/guide/shared-services/).
## Several AI agents, each in its own worktree
[Section titled “Several AI agents, each in its own worktree”](#several-ai-agents-each-in-its-own-worktree)
Give each agent a branch, a directory and a running stack of its own. Every command below prompts for nothing, and `--output json` gives it a document to read instead of text:
```bash
wtm agents install # once: the using-wtm skill for Claude Code / Cursor
branch=agent/fix-checkout
wtm create "$branch" --if-not-exists --yes --output json # {"branch", "path", "isolation", ...}
cd "$(wtm resolve "$branch")"
wtm run up "$branch" -d --yes --output json # per-job status, ports and URLs
api=$(wtm run url "$branch" --job api) # http://api.agent-fix-checkout.acme.localhost:11080
curl -s "$api/health"
wtm run logs "$branch" --output json # the last 1000 lines of each job
wtm run down "$branch" --yes
wtm clean "$branch" --yes # add --force once the work is pushed elsewhere
```
* `run up --yes` leaves the other worktrees' jobs running, so agents starting at the same time do not stop each other. Setting `concurrency = "parallel"` in `run.toml` makes that the answer for people too.
* Under `--yes` a missing choice is an error naming its flag, never a picker: `run start` needs `--job`, `create` needs the branch.
* Exit codes are stable: `10` the worktree already exists, `11` the branch does not exist, `14` a job or profile `run.toml` does not declare, `16` no `run.toml`, `2` a usage error.
* `wtm run ps --output json` lists everything running, across repositories, and `wtm list --output json` every worktree with its state.
* `clean --yes` still refuses a worktree with uncommitted or unpushed work; that refusal is lifted only by `--force`.
`wtm agents install` adds a skill to `.claude/` or `.cursor/` that teaches the agent these commands. Re-run it after upgrading wtm.
## Stacked pull requests
[Section titled “Stacked pull requests”](#stacked-pull-requests)
Each branch builds on the previous one, and every worktree records its parent:
```bash
wtm create feat/api --yes
wtm create feat/api-client --from feat/api --yes
wtm create feat/checkout-ui --from feat/api-client --yes
```
```console
$ wtm tree
main
└─ feat/api
└─ feat/api-client
└─ feat/checkout-ui
```
When `main` moves, or you amend `feat/api`, rebase the chain in order, parents first:
```bash
wtm sync --all --dry-run # the plan, nothing changed
wtm sync feat/api feat/api-client feat/checkout-ui
wtm sync --all --yes --push # unattended, then force-push with lease
```
On a conflict, `sync` aborts that branch's rebase and skips its descendants; `--keep-conflict` leaves the rebase in progress to resolve by hand. `--yes` never pushes unless `--push` is given.
When `feat/api` is merged (squash or rebase merges included), move its child onto `main` and remove it:
```bash
wtm reparent feat/api-client --to main --yes
wtm sync feat/api-client feat/checkout-ui --yes --push
wtm clean feat/api --yes
```
Or in one pass once several PRs are merged, with `gh` installed: `wtm prune --merged --reparent-children --yes`. `wtm tree --output mermaid` prints the stack as a flowchart for a PR description.
# run.toml reference
`/wtm/run.toml` (`.git/wtm/run.toml` in a normal clone) declares the jobs of the `run` module. It is per-clone and never committed; share it with `wtm run export | wtm run import -`. `wtm run init` writes it from detection, `wtm run job …` / `wtm run profile …` edit it, and a hand edit is fine: the file is validated every time it is read, and every write puts its JSON schema beside it (`schemas/run.schema.json`) for editor autocomplete.
**Validation is strict.** An unknown key, a misspelt `kind`, a reference to an undeclared job, or two links claiming the same key refuse the file, naming the cause. A refused file never fails a core command (`create`, `extract`, `checkout`, `env`, `clean`…): only the run part is skipped, with a warning. `run up` and `run start` refuse it; `run down`, `run stop` and `run ps` warn and carry on, so what is running can always be stopped.
## Top-level keys
[Section titled “Top-level keys”](#top-level-keys)
| Key | Default | Meaning |
| -------------------- | ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `isolation` | `"isolated"` | what a new worktree gets when nobody is asked: `"isolated"` or `"verbatim"`, see [Isolation](/0-28/guide/isolation/) |
| `addressing` | `"names"` | what an `[[env_port]]` link writes into a value pointing at a job that publishes a URL: its named origin (`"names"`) or its port (`"ports"`), see [Addressing](/0-28/guide/addressing/) |
| `concurrency` | unset (asked once) | the standing answer when another worktree runs jobs: `"parallel"` keeps them, `"exclusive"` stops them first |
| `port_offset_block` | `10` | the spacing between two worktrees' ports: worktree `n` binds `base + n × block` |
| `port_probe_timeout` | `15` | seconds `run up` / `run start` wait for a declared port to answer; a negative value turns the check off |
## `[[job]]`
[Section titled “\[\[job\]\]”](#job)
| Key | Required | Meaning |
| --------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `name` | yes | unique, no spaces |
| `kind` | yes | `"service"` (long-running) or `"task"` (one-shot) |
| `cmd` | yes | a `/bin/sh` line |
| `stop` | no | services only: a `/bin/sh` line bringing the service down. Its presence makes `cmd` a launcher wtm waits on (`docker compose up -d`) |
| `cwd` | no | working directory, relative to the worktree root |
| `ports` | no | `NAME = base` pairs: the job runs with `NAME=base + offset` in its environment |
| `url` | no | `{ port = "NAME", host = "api" }`: publish the declared port `NAME` under a named URL; `host` defaults to the job's name |
| `probe` | no | `false` skips the port check for this job |
| `binds_no_port` | no | `true` for a service that listens on nothing by design (a watcher, a worker, a runner) |
| `runs` | no | the declared jobs this one starts itself (`turbo run dev`); no cycles |
| `touches` | no | the declared services whose data this job changes, see [foreign data](/0-28/guide/isolation/#foreign-data-and-touches) |
| `scope` | no | `"shared"`: one instance for the repository, run in the main checkout, see [Shared services](/0-28/guide/shared-services/). Absent means one per worktree |
| `namespace` | no | shared services only: `{ name, create, remove, env }`, the worktree's own part of the service. `name` and `create` are required together |
A job runs with the worktree's identity in its environment: `WTM_BRANCH`, `WTM_WORKTREE` (the branch as a slug), `WTM_ORDINAL` (the main checkout is `0`), `WTM_PORT_OFFSET`, `WTM_ISOLATION`, `COMPOSE_PROJECT_NAME` (isolated worktrees and the main checkout) and its declared ports. Two base ports a multiple of `port_offset_block` apart are refused, since two worktrees would meet on the same port.
## `[[profile]]`
[Section titled “\[\[profile\]\]”](#profile)
| Key | Required | Meaning |
| --------- | -------- | ----------------------------------------------------------------------- |
| `name` | yes | unique, no spaces |
| `jobs` | yes | declared job names, in start order |
| `default` | no | `true` on at most one profile: what `run up` starts without `--profile` |
## `[[env_port]]`
[Section titled “\[\[env_port\]\]”](#env_port)
Links a `.env` key to a declared port, so the port inside its value follows the worktree:
| Key | Meaning |
| ------ | ----------------------------------------------------------------------------- |
| `file` | a `.env` target configured in `config.toml` |
| `key` | the key whose value carries the port |
| `job` | the job declaring the port, required since two jobs may both declare a `PORT` |
| `port` | the declared port name |
wtm finds the declared base inside the value and shifts only that number or, under `addressing = "names"`, writes the job's whole named origin when the value is a URL and the job publishes one. A value where the base is missing or appears twice is reported and left alone.
## `[[env]]`
[Section titled “\[\[env\]\]”](#env)
Writes a `.env` key's whole value from a template: what a worktree holds of a shared service, which no port can say:
| Key | Meaning |
| ------- | ----------------------------------------------------------------------------------- |
| `file` | a `.env` target configured in `config.toml` |
| `key` | the key wtm owns |
| `job` | the job the value speaks about |
| `value` | a template over `{namespace}`, `{port.NAME}`, `{origin}`, `{worktree}`, `{ordinal}` |
A key is written by an `[[env]]` link or an `[[env_port]]` link, never both.
## Example
[Section titled “Example”](#example)
```toml
isolation = "isolated"
addressing = "names"
concurrency = "parallel"
[[job]]
name = "docker"
kind = "service"
cmd = "docker compose up -d"
stop = "docker compose down"
[job.ports]
DB_PORT = 5432
[[job]]
name = "api"
kind = "service"
cmd = "pnpm dev"
cwd = "apps/api"
[job.ports]
PORT = 4000
[job.url]
port = "PORT"
[[job]]
name = "migrate"
kind = "task"
cmd = "pnpm migrate"
touches = ["docker"]
[[profile]]
name = "all"
jobs = ["docker", "migrate", "api"]
default = true
[[env_port]]
file = "apps/api/.env"
key = "DATABASE_URL"
job = "docker"
port = "DB_PORT"
```
# Shared services and namespaces
Isolation duplicates everything: two worktrees of a project with four postgres containers and a keycloak run eight postgres and two JVMs. A **shared service** runs once for the whole repository instead, and gives each worktree its own **namespace** in it: a database, a realm.
## Declaring one
[Section titled “Declaring one”](#declaring-one)
```toml
[[job]]
name = "postgres"
kind = "service"
cmd = "docker compose up -d postgres"
stop = "docker compose stop postgres"
scope = "shared"
[job.ports]
POSTGRES_PORT = 5432
[job.namespace]
name = "app_{worktree}" # app_feat-x in worktree feat/x
create = "scripts/db-worktree-add.sh" # run on every start: must be safe to run again
remove = "scripts/db-worktree-drop.sh" # run by wtm clean and wtm prune
```
`wtm run init` asks which compose services to share, then their namespace, row by row; `wtm run job add|edit` take the same fields as flags (`--scope shared`, `--namespace-name`, `--namespace-create`, `--namespace-remove`, `--namespace-env KEY=VALUE`).
Without a `[job.namespace]` the service is **shared outright**, data included: every worktree reads and writes the same data, which is [foreign data](/0-28/guide/isolation/#foreign-data-and-touches) to all of them.
## Where it runs
[Section titled “Where it runs”](#where-it-runs)
The real service runs in the **main checkout**, which never takes a port offset: a declared `5432` is the `5432` it binds, in every worktree. A worktree that starts it (`run up` of a profile holding it, or `run start`) starts it in the main checkout if it is not up yet, then holds it: `run ps` shows that hold as `joined`. The service stops only once no worktree holds it any more; `run down` in one worktree reports `released` for a service others still hold.
## The namespace commands
[Section titled “The namespace commands”](#the-namespace-commands)
wtm does not know what a database or a realm is: it runs your two commands at the right moment, with the right environment.
* `name` is data, never executed: wtm fills in `{worktree}` (the branch as a slug) and `{ordinal}`. `app_{worktree}` is the proposal.
* `create` runs on **every** start of the shared service, retried for a short while in case the service is not accepting connections yet. It must create the namespace if it is absent and do nothing if it is there.
* `remove` runs when the worktree is removed, never on `run stop` or `run down`. Leave it empty to keep the data.
Both are `/bin/sh` lines (or a script path) that read `$WTM_NAMESPACE`, `$WTM_WORKTREE`, `$WTM_ORDINAL`, the worktree's declared ports and URLs, and the extra variables of `namespace.env`. A `create` that clones main's database (`CREATE DATABASE "$WTM_NAMESPACE" TEMPLATE app`) starts each worktree from main's data without sharing it; see [the developer notes](/0-28/dev/shared-services/#starting-a-namespace-from-mains-data) for a complete script.
A worktree records each namespace it actually created in its `meta.json` (`namespaces`), the moment the service reports started. A worktree created and thrown away without ever starting the service owes nothing.
## Telling the app: `[[env]]`
[Section titled “Telling the app: \[\[env\]\]”](#telling-the-app-env)
A port link (`[[env_port]]`) says where the shared service answers: the same address for everyone. Which namespace a worktree holds is said by an `[[env]]` link, which writes a key's **whole** value from a template:
```toml
[[env]]
file = "apps/api/.env"
key = "DATABASE_URL"
job = "postgres"
value = "postgresql://app:app@localhost:{port.POSTGRES_PORT}/{namespace}"
```
The placeholders are `{namespace}`, `{port.NAME}` (a port of that job, as it resolves in the worktree), `{origin}` (the job's published address), `{worktree}` and `{ordinal}`; anything else is refused when `run.toml` is read. A key may be written by an `[[env]]` link or an `[[env_port]]` link, never both. The links are settled when a worktree is created and whenever `wtm env` reconciles it, no daemon needed.
## What `clean` and `prune` do with the data
[Section titled “What clean and prune do with the data”](#what-clean-and-prune-do-with-the-data)
Removing a worktree runs in a fixed order: its jobs are stopped and checked gone, its `on_clean` hooks run, git removes the worktree, and **only then** is its data dropped. A failure before that last step leaves the data where it was.
* **By default the namespaces are dropped**: the confirmation names each one, and `--output json` reports each as `dropped`, `deferred` or `kept`.
* `--keep-data` withholds the drop.
* A shared service that is down cannot drop anything. The interactive form asks whether to start it now or keep the data until wtm next starts it; `--yes` keeps it (`deferred`), `--drop-data` starts the service, drops, and lets it go again.
* A deferred drop is recorded in `pending-removals.toml` and paid the next time wtm starts that service (`run up`, `run start`) or runs `prune`. A drop that takes longer than 30 s is deferred the same way. If a worktree of the same name is created again before then, the debt is withdrawn, never paid.
* A namespace another live worktree reaches under the same name is never dropped (`kept`).
# Where wtm keeps its state
## Per repository: `/wtm/`
[Section titled “Per repository: \/wtm/”](#per-repository-git-common-dirwtm)
Everything wtm knows about a repository lives under its git common directory (`.git/wtm/` in a normal clone). Git never commits anything inside `.git/`, so none of it reaches your teammates or `git status`.
```plaintext
/wtm/
├── config.toml # project settings (wtm init)
├── run.toml # jobs and profiles (wtm run init), optional
├── schemas/ # JSON schemas, rewritten beside each file on every write
├── worktrees//
│ └── meta.json # one per worktree wtm created or adopted
├── logs//.log # each job's output, cleared when the job starts
├── hooks/-.log # the raw output of the last on_create / on_clean run
├── pending-removals.toml # namespace drops owed by a clean while their service was down
└── ordinal.lock # serialises the allocation of worktree numbers
```
`` is the branch name URL-escaped into one path segment (`feat/x` → `feat%2Fx`).
### `meta.json`
[Section titled “meta.json”](#metajson)
| Field | Meaning |
| --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `source_branch` | the parent `wtm sync` rebases onto |
| `created_at` | creation time |
| `env_strategy` | how its `.env` files were provisioned: `example`, `main` or `parent` |
| `ordinal` | its stable number, from which its ports are derived (`base + ordinal × port_offset_block`). Allocated on first need and released when the worktree is cleaned; the main checkout is `0` and has no `meta.json` |
| `isolation` | `isolated` or `verbatim`. Absent on a worktree created before v0.28, whose [adoption](/0-28/guide/isolation/#worktrees-created-before-v028) is pending |
| `namespaces` | the shared services it created a namespace in, which `clean` and `prune` drop |
### `pending-removals.toml`
[Section titled “pending-removals.toml”](#pending-removalstoml)
A `clean` or `prune` that could not drop a namespace (its shared service was down, the drop timed out, or was kept under `--yes`) records the debt here. It is paid the next time wtm starts that service or runs `prune`, and withdrawn if a worktree of the same name is created again first. See [Shared services](/0-28/guide/shared-services/#what-clean-and-prune-do-with-the-data).
## Per machine: beside the global config
[Section titled “Per machine: beside the global config”](#per-machine-beside-the-global-config)
The global config lives in the OS config directory (`~/.config/wtm/` on Linux, `~/Library/Application Support/wtm/` on macOS), and the run daemon keeps its files next to it:
```plaintext
/wtm/
├── config.toml # your personal settings: shell, [ui], [proxy]
├── state.json # what wtm writes for itself (the update check)
├── wtm.sock # the run daemon's socket, shared by every repository
├── wtm.lock # held by the one daemon running
└── jobs.json # the daemon's index of what it started
```
`jobs.json` is what makes the daemon disposable: it exits about 30 s after its last foreground job, detached services keep running without it, and the next daemon reads the index back, so `wtm run ps` still lists a compose stack after a reboot and `wtm run down` still stops it. `wtm run daemon status` reports what is up; `wtm run daemon restart` replaces a daemon of another wtm build.
On macOS, `wtm run proxy install` adds one file of its own: a LaunchAgent under `~/Library/LaunchAgents`, removed by `wtm run proxy uninstall`.
# Troubleshooting
Common problems, what causes them, and the command that fixes each one.
* [A port is already in use](#a-port-is-already-in-use)
* [A job is reported crashed](#a-job-is-reported-crashed)
* [The daemon is another version](#the-daemon-is-another-version)
* [A stack started by 0.27 is still running](#a-stack-started-by-027-is-still-running)
* [`wtm go` does not change directory](#wtm-go-does-not-change-directory)
* [No `run.toml` (exit 16)](#no-runtoml-exit-16)
* [A named URL does not answer](#a-named-url-does-not-answer)
* [A `.env` is out of date](#a-env-is-out-of-date)
## A port is already in use
[Section titled “A port is already in use”](#a-port-is-already-in-use)
**Symptom.** `wtm run up` reports a job started, then `wtm run ps` shows it `crashed`, and its log ends with `Address already in use` (or `EADDRINUSE`).
**Cause.** Something else listens on the port the worktree was given. Find it:
```bash
wtm run ps # another worktree's job?
lsof -nP -iTCP:5183 -sTCP:LISTEN # any other process
```
**Fix**, depending on what holds it:
* **An app you started by hand** (often on the base port, in the main checkout): stop it, or let wtm run it with `wtm run start --job `.
* **Another worktree's job**: every isolated worktree has its own ports, so this is usually a **verbatim** worktree and its source, which share their ports on purpose. `run up` offers to stop the other one; under `--yes`, pass `--exclusive`. To run both at once, give the worktree its own ports: `wtm env --isolation isolated`.
* **A port in another project** that happens to fall on one of yours: move this project's ports with `port_offset_block` at the top of `run.toml`, or change the base port with `wtm run job edit --port PORT= --yes`, then `wtm env ` to settle each worktree's `.env`.
**A related warning: "Ports declared but not bound".** Nothing answers on the port wtm gave the job, often because something answers on the base port instead. The command never read its variable: pass it explicitly (`--cmd 'pnpm dev --port ${PORT}'`), check that the app's `.env` does not pin a port, and in a Turborepo let the variable through (`globalPassThroughEnv` in `turbo.json`). `probe = false` on a job silences the check. See [Checking the ports](/0-28/guide/jobs-and-profiles/#checking-the-ports).
## A job is reported crashed
[Section titled “A job is reported crashed”](#a-job-is-reported-crashed)
**Symptom.** `wtm run ps` lists a job as `crashed`, or `run up` says it exited right after starting.
**Fix.** Read its output. The log is kept after the process is gone:
```bash
wtm run logs --job api # the run view, focused on api
wtm run logs feat/login --output json # the last 1000 lines of each job
```
The raw file is `.git/wtm/logs//.log`, cleared each time the job starts. Once the cause is fixed, `wtm run start --job api` starts it again; `wtm run ps --output json` gives the exit code (`exit_code`).
Frequent causes: a port in use (above), dependencies not installed in the new worktree (add `pnpm install` to the `on_create` hooks with `wtm init --only hooks`), or a command that only works from another directory (set the job's `cwd`).
## The daemon is another version
[Section titled “The daemon is another version”](#the-daemon-is-another-version)
**Symptom.** After an upgrade, a `wtm run` command stops with a message naming two versions: the daemon holding the socket, and this wtm.
**Cause.** Jobs are run by one background daemon shared by every repository, and it outlives the command that started it. A daemon from the previous binary keeps its own behavior until it is replaced. One holding no job is replaced automatically.
**Fix.**
```bash
wtm run daemon status # which build is running, and what it holds
wtm run daemon restart # hand the jobs over to this binary's daemon
```
Detached services (the ones with a `stop` command, such as a compose stack) survive the restart; foreground ones are stopped, so start them again with `wtm run up`.
## A stack started by 0.27 is still running
[Section titled “A stack started by 0.27 is still running”](#a-stack-started-by-027-is-still-running)
**Symptom.** After upgrading from 0.27, `wtm run up` starts a second copy of a compose stack, or fails on a port a running container holds, while `wtm run ps` shows nothing for it.
**Cause.** Before 0.28, jobs ran without `COMPOSE_PROJECT_NAME`, so compose named each stack after its directory (`feat-login`). wtm now names it `-` (`acme-feat-login`) and does not see the old one.
**Fix.** Stop each old stack once, by its old name:
```bash
docker compose ls # find the projects named after a directory
docker compose -p feat-login down # no -v: the volumes stay
```
The new stack uses new volumes (`acme-feat-login_*`), so it starts empty. A worktree created by 0.27 is also refused by `run up` until you decide its isolation: `wtm env --isolation isolated` (its own ports and project) or `--isolation verbatim` (keep its source's). See [Migrating to 0.28](/0-28/guide/migrating-to-028/).
## `wtm go` does not change directory
[Section titled “wtm go does not change directory”](#wtm-go-does-not-change-directory)
**Symptom.** `wtm go feat/login` prints `wtm go requires shell integration to change your working directory`, or prints nothing and you stay where you were.
**Cause.** A program cannot change its parent shell's directory; the shell function from `wtm shell-init` does it for it. It is missing from this shell.
**Fix.** Add it to your shell's startup file and open a new shell:
```bash
echo 'eval "$(wtm shell-init)"' >> ~/.zshrc # ~/.bashrc for bash
```
For fish, add `wtm shell-init | source` to `config.fish`. After an upgrade, open a new shell so the function matches the binary. In a script, where no startup file is read, use `cd "$(wtm resolve feat/login)"` instead.
## No `run.toml` (exit 16)
[Section titled “No run.toml (exit 16)”](#no-runtoml-exit-16)
**Symptom.** `wtm run up` (or `start`, `list`, `url`…) fails with `no run.toml` and exit code `16`.
**Cause.** The `run` module is opt-in, and `run.toml` lives in `.git/wtm/`: it is per clone and never committed. A new clone has none, even when a teammate's does. Every worktree of a clone shares the same file.
**Fix.** Create it from detection, or copy it from a clone that has one:
```bash
wtm run init # detect compose files and scripts
wtm run export > run.json # in the clone that has it
wtm run import run.json # in this one
```
After an import, `wtm env ` settles each worktree's `.env` on it.
## A named URL does not answer
[Section titled “A named URL does not answer”](#a-named-url-does-not-answer)
**Symptom.** `http://api.feat-login.acme.localhost:11080` does not load, while the job looks up.
Go through these in order:
1. **Is the job run by wtm?** Named URLs are served by the run proxy while `wtm run` runs the job. An app started by hand has none: use its port URL, printed by `wtm run url --job api --raw`. `wtm run ps` shows what wtm runs.
2. **Does the proxy listen?** `wtm run proxy status` prints the port it binds and the one URLs carry. When another program holds the port, the names are lost but the jobs still run: set another port in the global config (its path is in the status output) and `wtm run daemon restart`.
```toml
[proxy]
port = 11090
```
3. **Does the client resolve `*.localhost`?** Browsers and curl send `*.localhost` to the loopback; some other HTTP clients and tools do not. Use `wtm run url --raw` for those.
4. **Is the URL still current?** A branch's host is its slug (`feat/login` becomes `feat-login`). `wtm run url feat/login --job api` prints the exact one.
Without the port: on macOS, `wtm run proxy install` serves the names on port 80, then `wtm env ` drops the port from the `.env` values. To use port URLs everywhere, `wtm run addressing ports`. See [Named URLs](/0-28/guide/addressing/).
## A `.env` is out of date
[Section titled “A .env is out of date”](#a-env-is-out-of-date)
**Symptom.** A worktree's `.env` misses a key the template gained, still carries a value from before, or points at the wrong port after `run.toml` changed.
**Fix.** Compare it with its source, then reconcile:
```bash
wtm env feat/login --check # read-only report
wtm env feat/login # add missing keys, settle the ports
wtm env feat/login --mode refresh --on-conflict overwrite --yes # also overwrite diverging values
wtm env feat/login --prune --yes # drop keys no source has any more
```
The values come from the strategy the worktree was created with (`example`, `main` or `parent`); `--from` overrides it for one run. The ports and addresses `run.toml` links are settled on the worktree's own at the same time. `wtm env main` does the same for the main checkout.
# wtm
Orchestrate git worktrees and team dev workflows from the terminal
```plaintext
wtm [flags]
```
### Examples
[Section titled “Examples”](#examples)
```plaintext
# Once per repository
wtm init
# A worktree per branch, then jump into it
wtm create feat/login
wtm go feat/login
# Every worktree, its PR and its services, on one screen
wtm ui
```
### Options
[Section titled “Options”](#options)
```plaintext
-h, --help help for wtm
-q, --quiet Silence human output; errors and the exit code are unaffected, and --output json still emits its document
```
### SEE ALSO
[Section titled “SEE ALSO”](#see-also)
* [wtm agents](/0-28/reference/wtm-agents/) - Manage LLM agent integrations for wtm
* [wtm checkout](/0-28/reference/wtm-checkout/) - Create a worktree from an existing pull request
* [wtm clean](/0-28/reference/wtm-clean/) - Remove a worktree and its local branch
* [wtm config](/0-28/reference/wtm-config/) - Inspect or edit the project wtm config
* [wtm create](/0-28/reference/wtm-create/) - Create a new worktree
* [wtm env](/0-28/reference/wtm-env/) - Reconcile a worktree's .env against its template and value sources
* [wtm extract](/0-28/reference/wtm-extract/) - Move uncommitted changes to another worktree
* [wtm fast-forward](/0-28/reference/wtm-fast-forward/) - Advance worktree branches to their origin counterpart
* [wtm go](/0-28/reference/wtm-go/) - Switch to a worktree
* [wtm init](/0-28/reference/wtm-init/) - Initialize wtm configuration
* [wtm list](/0-28/reference/wtm-list/) - List all worktrees
* [wtm prune](/0-28/reference/wtm-prune/) - Remove finished worktrees (merged, closed PR or gone) in one pass
* [wtm relocate](/0-28/reference/wtm-relocate/) - Move worktrees to align with base_path and adopt external ones
* [wtm reparent](/0-28/reference/wtm-reparent/) - Change the parent one or more worktrees are rebased onto
* [wtm resolve](/0-28/reference/wtm-resolve/) - Resolve a branch to its worktree path
* [wtm run](/0-28/reference/wtm-run/) - Manage dev jobs (services + tasks)
* [wtm schema](/0-28/reference/wtm-schema/) - Inspect or extract bundled JSON Schemas
* [wtm shell-init](/0-28/reference/wtm-shell-init/) - Generate shell integration function
* [wtm sync](/0-28/reference/wtm-sync/) - Rebase selected worktrees onto their parent, in cascade
* [wtm tree](/0-28/reference/wtm-tree/) - Show the worktree forest (parent → child)
* [wtm ui](/0-28/reference/wtm-ui/) - Open the worktree dashboard
* [wtm upgrade](/0-28/reference/wtm-upgrade/) - Update wtm to the latest release
# wtm agents
Manage LLM agent integrations for wtm
```plaintext
wtm agents [flags]
```
### Options
[Section titled “Options”](#options)
```plaintext
-h, --help help for agents
```
### Options inherited from parent commands
[Section titled “Options inherited from parent commands”](#options-inherited-from-parent-commands)
```plaintext
-q, --quiet Silence human output; errors and the exit code are unaffected, and --output json still emits its document
```
### SEE ALSO
[Section titled “SEE ALSO”](#see-also)
* [wtm](/0-28/reference/wtm/) - Orchestrate git worktrees and team dev workflows from the terminal
* [wtm agents install](/0-28/reference/wtm-agents-install/) - Install the using-wtm skill into .claude / .cursor
# wtm agents install
Install the using-wtm skill into .claude / .cursor
### Synopsis
[Section titled “Synopsis”](#synopsis)
Detects which skill destinations exist (project and home-level .claude and .cursor) and installs the using-wtm skill into the ones you pick.
```plaintext
wtm agents install [flags]
```
### Examples
[Section titled “Examples”](#examples)
```plaintext
wtm agents install
# Every detected destination, no questions
wtm agents install --yes
# Also create the ones that don't exist yet, and report as JSON
wtm agents install --all --yes --output json
```
### Options
[Section titled “Options”](#options)
```plaintext
--all Include destinations that don't yet exist (creates skill dirs)
-h, --help help for install
--output string Output format: text or json (default "text")
--yes Non-interactive: install into every detected destination
```
### Options inherited from parent commands
[Section titled “Options inherited from parent commands”](#options-inherited-from-parent-commands)
```plaintext
-q, --quiet Silence human output; errors and the exit code are unaffected, and --output json still emits its document
```
### SEE ALSO
[Section titled “SEE ALSO”](#see-also)
* [wtm agents](/0-28/reference/wtm-agents/) - Manage LLM agent integrations for wtm
# wtm checkout
Create a worktree from an existing pull request
### Synopsis
[Section titled “Synopsis”](#synopsis)
Create a worktree from a pull request. A local branch of the PR's name is checked out as-is, keeping commits you never pushed; interactive runs offer to fast-forward it when it is behind origin. Without arguments, shows an interactive picker of open PRs.
```plaintext
wtm checkout [number] [flags]
```
### Examples
[Section titled “Examples”](#examples)
```plaintext
# Pick among the open pull requests
wtm checkout
# Only the ones waiting for your review
wtm checkout --review
wtm checkout 42
# No prompts, with a JSON result
wtm checkout 42 --yes --output json
```
### Options
[Section titled “Options”](#options)
```plaintext
--env-from string Override env strategy (example, main, parent)
--from string Parent branch for sync (defaults to the PR base branch)
-h, --help help for checkout
--isolation string How the new worktree stands against its source: isolated (its own ports, compose project and namespaces in shared services, in the .env and at run time) or verbatim (.env kept exactly as copied, run on its source's ports and data); defaults to run.toml's isolation, else isolated
--mine Show only your PRs
--output string Output format: text or json (default "text")
--review Show only PRs where you are requested as reviewer
-y, --yes Skip all prompts; resolve every decision from flags and safe defaults (PR number required)
```
### Options inherited from parent commands
[Section titled “Options inherited from parent commands”](#options-inherited-from-parent-commands)
```plaintext
-q, --quiet Silence human output; errors and the exit code are unaffected, and --output json still emits its document
```
### SEE ALSO
[Section titled “SEE ALSO”](#see-also)
* [wtm](/0-28/reference/wtm/) - Orchestrate git worktrees and team dev workflows from the terminal
# wtm clean
Remove a worktree and its local branch
### Synopsis
[Section titled “Synopsis”](#synopsis)
Remove a git worktree and delete the local branch. The remote branch is never touched. Without arguments, shows an interactive picker.
The removal runs in a fixed order: the worktree's jobs are stopped and checked gone (a job that will not stop refuses the removal unless --force), the on_clean hooks run, git removes the worktree — and only then is its data dropped. A failure before that last step leaves the data where it was.
By default, clean DROPS the namespaces the worktree carved out of shared services (a database per worktree in a shared postgres, say): the confirmation names each one, and --output json reports each as dropped, deferred or kept. --keep-data withholds the drop. A service that is down cannot take its data back: the form asks whether to start it now or keep the data until wtm next starts it; --yes keeps it, --drop-data starts it. A namespace another worktree reaches under the same name is never dropped.
```plaintext
wtm clean [branch] [flags]
```
### Examples
[Section titled “Examples”](#examples)
```plaintext
# Pick the worktree to remove
wtm clean
wtm clean feat/login
# No prompts; its children move onto its parent
wtm clean feat/login --yes --reparent-children
# Keep the databases it holds in shared services
wtm clean feat/login --yes --keep-data --output json
```
### Options
[Section titled “Options”](#options)
```plaintext
--drop-data Drop the removed worktrees' data now, starting the shared services that are down to do it
--force Lift safety refusals (dirty/unpushed/open-PR); still asks to confirm unless --yes
-h, --help help for clean
--keep-data Keep the namespaces the removed worktrees carved out of shared services
--output string Output format: text or json (default "text")
--reparent-children Reparent orphaned child worktrees onto the grandparent (no prompt)
-y, --yes Skip all prompts; resolve every decision from flags and safe defaults (keeps safety checks unless --force)
```
### Options inherited from parent commands
[Section titled “Options inherited from parent commands”](#options-inherited-from-parent-commands)
```plaintext
-q, --quiet Silence human output; errors and the exit code are unaffected, and --output json still emits its document
```
### SEE ALSO
[Section titled “SEE ALSO”](#see-also)
* [wtm](/0-28/reference/wtm/) - Orchestrate git worktrees and team dev workflows from the terminal
# wtm config
Inspect or edit the project wtm config
### Synopsis
[Section titled “Synopsis”](#synopsis)
View the resolved config or open the project config.toml in $EDITOR. The file lives under /wtm/config.toml and is never committed.
```plaintext
wtm config [flags]
```
### Examples
[Section titled “Examples”](#examples)
```plaintext
wtm config show
wtm config edit
```
### Options
[Section titled “Options”](#options)
```plaintext
-h, --help help for config
```
### Options inherited from parent commands
[Section titled “Options inherited from parent commands”](#options-inherited-from-parent-commands)
```plaintext
-q, --quiet Silence human output; errors and the exit code are unaffected, and --output json still emits its document
```
### SEE ALSO
[Section titled “SEE ALSO”](#see-also)
* [wtm](/0-28/reference/wtm/) - Orchestrate git worktrees and team dev workflows from the terminal
* [wtm config edit](/0-28/reference/wtm-config-edit/) - Open the project config.toml in $EDITOR
* [wtm config show](/0-28/reference/wtm-config-show/) - Print the project config.toml
# wtm config edit
Open the project config.toml in $EDITOR
### Synopsis
[Section titled “Synopsis”](#synopsis)
Launch the editor on /wtm/config.toml. After save, the file is re-validated and any error is reported.
```plaintext
wtm config edit [flags]
```
### Examples
[Section titled “Examples”](#examples)
```plaintext
wtm config edit
EDITOR=vim wtm config edit
```
### Options
[Section titled “Options”](#options)
```plaintext
-h, --help help for edit
```
### Options inherited from parent commands
[Section titled “Options inherited from parent commands”](#options-inherited-from-parent-commands)
```plaintext
-q, --quiet Silence human output; errors and the exit code are unaffected, and --output json still emits its document
```
### SEE ALSO
[Section titled “SEE ALSO”](#see-also)
* [wtm config](/0-28/reference/wtm-config/) - Inspect or edit the project wtm config
# wtm config show
Print the project config.toml
```plaintext
wtm config show [flags]
```
### Examples
[Section titled “Examples”](#examples)
```plaintext
wtm config show
# Check the file, print nothing else
wtm config show --validate
wtm config show --output json
```
### Options
[Section titled “Options”](#options)
```plaintext
-h, --help help for show
--output string Output format: text or json (default "text")
--validate Validate the config instead of printing it
```
### Options inherited from parent commands
[Section titled “Options inherited from parent commands”](#options-inherited-from-parent-commands)
```plaintext
-q, --quiet Silence human output; errors and the exit code are unaffected, and --output json still emits its document
```
### SEE ALSO
[Section titled “SEE ALSO”](#see-also)
* [wtm config](/0-28/reference/wtm-config/) - Inspect or edit the project wtm config
# wtm create
Create a new worktree
### Synopsis
[Section titled “Synopsis”](#synopsis)
Create a git worktree with env provisioning, metadata, and hooks. A branch that already exists locally is checked out as-is, keeping its commits. Its parent can't be inferred, so --from then names the branch recorded for `wtm sync` — asked in the wizard, required without it. When run.toml declares jobs, a branch whose derived name a live worktree already carries (feat.x next to feat/x: one compose project, one proxy host) is refused. Without arguments, prompts for the branch name interactively.
```plaintext
wtm create [branch] [flags]
```
### Examples
[Section titled “Examples”](#examples)
```plaintext
# Answer the wizard: branch, source, env strategy, isolation
wtm create
# A new branch from the base branch, no prompts
wtm create feat/login --yes
# A stacked branch on top of feat/login
wtm create feat/login-ui --from feat/login --yes
# For a script or an agent: idempotent, with a JSON result
wtm create feat/login --if-not-exists --yes --output json
```
### Options
[Section titled “Options”](#options)
```plaintext
--env-from string Override env strategy (example, main, parent)
--ff Fast-forward to origin before creating — the source branch, or the branch itself when it already exists locally (non-interactive; skipped when it has diverged)
--from string Source branch to start from — or, when the branch already exists locally, the parent to record for wtm sync (required there without the wizard)
-h, --help help for create
--if-not-exists Succeed silently if the worktree already exists (idempotent)
--isolation string How the new worktree stands against its source: isolated (its own ports, compose project and namespaces in shared services, in the .env and at run time) or verbatim (.env kept exactly as copied, run on its source's ports and data); defaults to run.toml's isolation, else isolated
--output string Output format: text or json (default "text")
-y, --yes Skip all prompts; resolve every decision from flags and safe defaults (branch name required; source defaults to the base branch for a new branch, and --from is required for one that already exists)
```
### Options inherited from parent commands
[Section titled “Options inherited from parent commands”](#options-inherited-from-parent-commands)
```plaintext
-q, --quiet Silence human output; errors and the exit code are unaffected, and --output json still emits its document
```
### SEE ALSO
[Section titled “SEE ALSO”](#see-also)
* [wtm](/0-28/reference/wtm/) - Orchestrate git worktrees and team dev workflows from the terminal
# wtm env
Reconcile a worktree's .env against its template and value sources
### Synopsis
[Section titled “Synopsis”](#synopsis)
Detect and fix .env drift in a worktree: add expected-but-missing keys and (with --mode refresh) settle values that diverge from the source.
Values come from the strategy the worktree was created with, shown in the report: example → the template's placeholders, main → the main checkout, parent → the parent worktree only (main is read only when the parent has no worktree or no such file). Override it per run with --from.
Pass a worktree branch, or omit it to pick interactively. --check prints a read-only drift report. Non-interactively (--yes / --output json) it applies only safe additions; conflicts need --on-conflict and orphans need --prune.
When run.toml declares ports, a second pass follows on the reconciled files of an isolated worktree: each \[\[env_port]] link and \[\[env]] value is settled on its own ports and namespaces, and COMPOSE_PROJECT_NAME is written for a compose job. An invalid run.toml skips that pass with a warning; the keys are still reconciled. The main checkout is a worktree like any other here: `wtm env main` settles it on run.toml's declared ports, which it never shifts.
A worktree created before the isolation choice existed (no isolation in its meta.json) keeps its source's ports and COMPOSE_PROJECT_NAME: non-interactively only its keys are reconciled, and the report says so. The wizard offers to adopt isolation — a new compose project, so its current volumes are no longer used — and --isolation isolated adopts it explicitly.
\--isolation verbatim puts the values wtm owns (linked ports, \[\[env]] values, COMPOSE_PROJECT_NAME) back to the source's and leaves every other key alone: the worktree then shares its source's compose volumes and data. The wizard shows those values first, and its recap can also keep a worktree verbatim from then on. Either isolation is recorded only once the .env is in line with it: a run that fails or is cancelled records nothing.
```plaintext
wtm env [worktree] [flags]
```
### Examples
[Section titled “Examples”](#examples)
```plaintext
# Pick a worktree and reconcile its .env files
wtm env
# Read-only drift report
wtm env feat/login --check
# Also settle the values that diverge from the source
wtm env feat/login --mode refresh --on-conflict overwrite --yes
# Give a worktree created before 0.28 its own ports and compose project
wtm env feat/login --isolation isolated --yes
```
### Options
[Section titled “Options”](#options)
```plaintext
--check Read-only drift report; write nothing
--from string Override the value source strategy (example, main, parent)
-h, --help help for env
--isolation string Settle the worktree on an isolation, recorded once its .env is in line: isolated (wtm moves its ports, compose project and namespaces, in the .env and at run time) or verbatim (the values wtm owns go back to the source's, and it runs on the ports its .env keeps)
--mode string Reconciliation mode: add (fill gaps) or refresh (also settle value conflicts) (default "add")
--on-conflict string Non-interactive conflict resolution: keep (default) or overwrite
--output string Output format: text or json (default "text")
--prune Remove orphan keys (present in the .env but in no source)
-y, --yes Skip all prompts; apply safe additions and flag-driven decisions only
```
### Options inherited from parent commands
[Section titled “Options inherited from parent commands”](#options-inherited-from-parent-commands)
```plaintext
-q, --quiet Silence human output; errors and the exit code are unaffected, and --output json still emits its document
```
### SEE ALSO
[Section titled “SEE ALSO”](#see-also)
* [wtm](/0-28/reference/wtm/) - Orchestrate git worktrees and team dev workflows from the terminal
# wtm extract
Move uncommitted changes to another worktree
### Synopsis
[Section titled “Synopsis”](#synopsis)
Move a subset of a worktree's uncommitted changes to another worktree (new or existing) to split an oversized PR or isolate unrelated work.
The source worktree is the first thing chosen: pass its branch as \[source], or omit it to pick interactively from the worktrees that have changes. A source is required when there is no terminal or with --output json.
A --to branch that already exists locally is checked out as-is, keeping its commits. Its parent can't be inferred, so --from then names the branch recorded for `wtm sync` — asked in the wizard, required without it.
Untracked files are listed one by one, including inside brand-new directories, so you can take part of a new folder; gitignored files are never listed.
On conflict it aborts by default, leaving the source intact; --on-conflict resolve applies conflict markers in the target so you can resolve them like a rebase. A file that merely already exists in the target counts as a conflict too.
```plaintext
wtm extract [source] [flags]
```
### Examples
[Section titled “Examples”](#examples)
```plaintext
# Pick the source, the files and the target
wtm extract
# Move a directory's changes to a new branch
wtm extract feat/login --files apps/api --to feat/login-api --yes
# Copy one file instead, onto a branch stacked on the source
wtm extract feat/login --files apps/web/login.ts --to feat/login-web --from feat/login --keep --yes
```
### Options
[Section titled “Options”](#options)
```plaintext
--ff Fast-forward the parent branch to origin before creating the target (non-interactive; skipped when it has diverged)
--files strings Files to extract, or a directory to take everything below it (skips interactive selection)
--from string Parent branch when creating the target worktree
-h, --help help for extract
--isolation string How the new worktree stands against its source: isolated (its own ports, compose project and namespaces in shared services, in the .env and at run time) or verbatim (.env kept exactly as copied, run on its source's ports and data); defaults to run.toml's isolation, else isolated
--keep Copy instead of move (keep the changes in the source)
--on-conflict string On conflict: abort (default) or resolve (write conflict markers in the target)
--output string Output format: text or json (default "text")
--to string Target worktree branch; created if it does not exist
-y, --yes Skip all prompts; resolve every decision from flags and safe defaults (requires a source arg, --files and --to; --from is also required when --to already exists locally; errors if a selection is missing)
```
### Options inherited from parent commands
[Section titled “Options inherited from parent commands”](#options-inherited-from-parent-commands)
```plaintext
-q, --quiet Silence human output; errors and the exit code are unaffected, and --output json still emits its document
```
### SEE ALSO
[Section titled “SEE ALSO”](#see-also)
* [wtm](/0-28/reference/wtm/) - Orchestrate git worktrees and team dev workflows from the terminal
# wtm fast-forward
Advance worktree branches to their origin counterpart
### Synopsis
[Section titled “Synopsis”](#synopsis)
Fast-forward one or more managed worktrees to origin/, and nothing else: no rebase onto the parent, no merge. Pass branch names, --all for every worktree, or no arguments to pick interactively. A branch that has diverged from origin is refused — `wtm sync` is the command that replays local commits onto it, and --force does not lift that refusal. A worktree with uncommitted changes is refused too; --force fast-forwards it anyway, and git still refuses if a modified file would be overwritten.
```plaintext
wtm fast-forward [branch...] [flags]
```
### Examples
[Section titled “Examples”](#examples)
```plaintext
# Pick the worktrees to bring up to origin
wtm fast-forward
wtm ff feat/login
# Every worktree, no prompts
wtm fast-forward --all --yes --output json
```
### Options
[Section titled “Options”](#options)
```plaintext
--all Fast-forward every managed worktree
--force Fast-forward a worktree that has uncommitted changes
-h, --help help for fast-forward
--output string Output format: text or json (default "text")
-y, --yes Skip all prompts; resolve every decision from flags and safe defaults (requires branch args or --all)
```
### Options inherited from parent commands
[Section titled “Options inherited from parent commands”](#options-inherited-from-parent-commands)
```plaintext
-q, --quiet Silence human output; errors and the exit code are unaffected, and --output json still emits its document
```
### SEE ALSO
[Section titled “SEE ALSO”](#see-also)
* [wtm](/0-28/reference/wtm/) - Orchestrate git worktrees and team dev workflows from the terminal
# wtm go
Switch to a worktree
### Synopsis
[Section titled “Synopsis”](#synopsis)
Navigate to a worktree directory. Requires shell integration to work.
```plaintext
wtm go [branch] [flags]
```
### Examples
[Section titled “Examples”](#examples)
```plaintext
# Pick a worktree
wtm go
wtm go feat/login
# Back to the main checkout
wtm go main
```
### Options
[Section titled “Options”](#options)
```plaintext
-h, --help help for go
```
### Options inherited from parent commands
[Section titled “Options inherited from parent commands”](#options-inherited-from-parent-commands)
```plaintext
-q, --quiet Silence human output; errors and the exit code are unaffected, and --output json still emits its document
```
### SEE ALSO
[Section titled “SEE ALSO”](#see-also)
* [wtm](/0-28/reference/wtm/) - Orchestrate git worktrees and team dev workflows from the terminal
# wtm init
Initialize wtm configuration
### Synopsis
[Section titled “Synopsis”](#synopsis)
Interactive wizard to set up global config and project config in /wtm/config.toml. Pass --yes (or any config flag) to bootstrap from flags + auto-detection instead; without a terminal, init does so on its own and never prompts. Use --only env|hooks|worktrees to re-run init for specific sections and regenerate them cleanly. Services & tasks are configured separately with `wtm run init`.
```plaintext
wtm init [flags]
```
### Examples
[Section titled “Examples”](#examples)
```plaintext
# The wizard
wtm init
# Unattended, from detection
wtm init --yes
wtm init --yes --base-path ../acme.trees --install-command "pnpm install"
# Regenerate the hooks section only
wtm init --only hooks
```
### Options
[Section titled “Options”](#options)
```plaintext
--base-branch string Default base branch for new worktrees
--base-path string Worktree directory, relative to repo root
--clean-command string Command to run before removing a worktree
--env-strategy string Env provisioning strategy: example, main, or parent
-h, --help help for init
--install-command string Command to run after creating a worktree
--only strings Re-init only these sections (env, hooks, worktrees); regenerates them cleanly
--shell string Global shell: zsh, bash, or fish
--skip-clean Skip on_clean hooks config
--skip-env Skip .env provisioning config
--skip-hooks Skip on_create hooks config
-y, --yes Run unattended: bootstrap (or re-init) from flags + auto-detection; never prompt
```
### Options inherited from parent commands
[Section titled “Options inherited from parent commands”](#options-inherited-from-parent-commands)
```plaintext
-q, --quiet Silence human output; errors and the exit code are unaffected, and --output json still emits its document
```
### SEE ALSO
[Section titled “SEE ALSO”](#see-also)
* [wtm](/0-28/reference/wtm/) - Orchestrate git worktrees and team dev workflows from the terminal
# wtm list
List all worktrees
### Synopsis
[Section titled “Synopsis”](#synopsis)
List all git worktrees with their status, PR info, and running services.
```plaintext
wtm list [flags]
```
### Examples
[Section titled “Examples”](#examples)
```plaintext
wtm list
# With each worktree's pull request, as JSON
wtm list --with-prs --output json
```
### Options
[Section titled “Options”](#options)
```plaintext
-h, --help help for list
--output string Output format: text or json (default "text")
--with-prs Include GitHub PR info in non-interactive output (fetched eagerly)
```
### Options inherited from parent commands
[Section titled “Options inherited from parent commands”](#options-inherited-from-parent-commands)
```plaintext
-q, --quiet Silence human output; errors and the exit code are unaffected, and --output json still emits its document
```
### SEE ALSO
[Section titled “SEE ALSO”](#see-also)
* [wtm](/0-28/reference/wtm/) - Orchestrate git worktrees and team dev workflows from the terminal
# wtm prune
Remove finished worktrees (merged, closed PR or gone) in one pass
### Synopsis
[Section titled “Synopsis”](#synopsis)
Batch-remove worktrees whose work is done, reparenting any surviving children onto their grandparent (like `clean --reparent-children`). Whether work is "done" is read from GitHub via the `gh` CLI — never guessed from local commits — so squash- and rebase-merges are detected correctly. By default prune considers every finished worktree: merged PR, closed PR, or upstream branch gone. The reason flags restrict to specific categories — --merged (PR merged), --closed (PR closed unmerged), --gone (remote branch deleted).
\--merged and --closed require the GitHub CLI (`gh`) to be installed and authenticated; without it they match nothing and prune prints a notice — only --gone still applies. gone-detection runs `git fetch --prune` first so deleted remote branches are seen (pass --no-fetch to skip).
On a TTY, matches are shown for review (unsafe ones unchecked), then a prune confirmation, then — like clean — a dedicated confirmation to reparent surviving children onto their grandparent (or leave them orphaned). The main checkout and base branch are always protected; the current worktree is removed and the shell redirected to the base repo. Like clean, worktrees that are dirty, have unpushed commits, or have an open PR are unsafe and need --force. Use --yes to skip the prompts (required with --output json); non-interactively, children are left orphaned unless --reparent-children is passed. --dry-run previews without changing anything.
Like clean, prune gives back the data the removed worktrees carved out of shared services (--keep-data withholds it); when such a service is down, the form asks whether to start it and drop the data now, or keep it until the service next starts. --yes keeps it; --drop-data drops it, starting the services that are down.
Each worktree goes through clean's whole sequence — jobs stopped, hooks, removal, then its data — before the next one starts. The first that fails stops the prune: the ones before it are gone with their data, it and the ones after keep theirs, and the report (and the `failed` field of --output json) names where it stopped.
```plaintext
wtm prune [flags]
```
### Examples
[Section titled “Examples”](#examples)
```plaintext
# Review every finished worktree, then confirm
wtm prune
# Only show what would go
wtm prune --dry-run
# Every worktree whose PR was merged, no prompts
wtm prune --merged --yes --reparent-children
```
### Options
[Section titled “Options”](#options)
```plaintext
--closed Restrict to worktrees whose PR was closed without merging (needs gh)
--drop-data Drop the removed worktrees' data now, starting the shared services that are down to do it
--dry-run Preview what would be pruned without removing anything
--force Lift safety refusals (dirty/unpushed/open-PR): also remove unsafe worktrees; still asks to confirm unless --yes
--gone Restrict to worktrees whose upstream branch was deleted on the remote
-h, --help help for prune
--keep-data Keep the namespaces the removed worktrees carved out of shared services
--merged Restrict to worktrees whose PR was merged on GitHub (needs gh)
--no-fetch Skip the git fetch --prune that gone-detection performs; use already-fetched state
--output string Output format: text or json (default "text")
--reparent-children Reparent orphaned child worktrees onto the grandparent (no prompt)
-y, --yes Skip all prompts; keep every match without the selection picker (use --force for unsafe worktrees)
```
### Options inherited from parent commands
[Section titled “Options inherited from parent commands”](#options-inherited-from-parent-commands)
```plaintext
-q, --quiet Silence human output; errors and the exit code are unaffected, and --output json still emits its document
```
### SEE ALSO
[Section titled “SEE ALSO”](#see-also)
* [wtm](/0-28/reference/wtm/) - Orchestrate git worktrees and team dev workflows from the terminal
# wtm relocate
Move worktrees to align with base_path and adopt external ones
### Synopsis
[Section titled “Synopsis”](#synopsis)
Reconcile every worktree with the configured base_path. Worktrees not under it are moved (git worktree move) and worktrees created outside wtm are adopted (their parent recorded so `wtm sync` works). Pass --to to change base_path and move existing worktrees to the new location. Dirty or locked worktrees are skipped unless --force; an occupied target path is never overwritten, and a worktree whose jobs are running is never moved (stop them with `wtm run down ` first). Adoption keeps what the worktree's meta.json already records (isolation, namespaces, ordinal).
```plaintext
wtm relocate [flags]
```
### Examples
[Section titled “Examples”](#examples)
```plaintext
# Show the plan first
wtm relocate --dry-run
wtm relocate
# Move every worktree under a new directory
wtm relocate --to ../acme.trees --yes
```
### Options
[Section titled “Options”](#options)
```plaintext
--dry-run Preview the plan without moving or adopting anything
--force Lift safety refusals (dirty/locked): move those worktrees too; still asks to confirm unless --yes
-h, --help help for relocate
--output string Output format: text or json (default "text")
--to string New base_path (relative to repo root); also moves existing worktrees there
-y, --yes Skip all prompts; resolve every decision from flags and safe defaults (parents default to the base branch)
```
### Options inherited from parent commands
[Section titled “Options inherited from parent commands”](#options-inherited-from-parent-commands)
```plaintext
-q, --quiet Silence human output; errors and the exit code are unaffected, and --output json still emits its document
```
### SEE ALSO
[Section titled “SEE ALSO”](#see-also)
* [wtm](/0-28/reference/wtm/) - Orchestrate git worktrees and team dev workflows from the terminal
# wtm reparent
Change the parent one or more worktrees are rebased onto
### Synopsis
[Section titled “Synopsis”](#synopsis)
Change the recorded parent (source branch) of one or more worktrees. Only the metadata is updated — the rebase happens on the next `wtm sync`. Pass the worktrees and --to , or run with no arguments to multi-select interactively. The new parent must exist as a local or origin remote-tracking branch (origin/x), and the resulting parent chain must stay acyclic.
```plaintext
wtm reparent [branch...] [flags]
```
### Examples
[Section titled “Examples”](#examples)
```plaintext
# Pick the worktrees and their new parent
wtm reparent
# feat/login was merged: stack its child on main, then rebase it
wtm reparent feat/login-ui --to main --yes
wtm sync feat/login-ui
```
### Options
[Section titled “Options”](#options)
```plaintext
-h, --help help for reparent
--output string Output format: text or json (default "text")
--to string New parent branch to rebase onto
-y, --yes Skip all prompts; resolve every decision from flags and safe defaults (needs at least one worktree and --to)
```
### Options inherited from parent commands
[Section titled “Options inherited from parent commands”](#options-inherited-from-parent-commands)
```plaintext
-q, --quiet Silence human output; errors and the exit code are unaffected, and --output json still emits its document
```
### SEE ALSO
[Section titled “SEE ALSO”](#see-also)
* [wtm](/0-28/reference/wtm/) - Orchestrate git worktrees and team dev workflows from the terminal
# wtm resolve
Resolve a branch to its worktree path
```plaintext
wtm resolve [branch] [flags]
```
### Examples
[Section titled “Examples”](#examples)
```plaintext
wtm resolve feat/login
# Use it in a script
cd "$(wtm resolve feat/login)"
wtm resolve feat/login --output json
```
### Options
[Section titled “Options”](#options)
```plaintext
-h, --help help for resolve
--output string Output format: text or json (default "text")
```
### Options inherited from parent commands
[Section titled “Options inherited from parent commands”](#options-inherited-from-parent-commands)
```plaintext
-q, --quiet Silence human output; errors and the exit code are unaffected, and --output json still emits its document
```
### SEE ALSO
[Section titled “SEE ALSO”](#see-also)
* [wtm](/0-28/reference/wtm/) - Orchestrate git worktrees and team dev workflows from the terminal
# wtm run
Manage dev jobs (services + tasks)
### Synopsis
[Section titled “Synopsis”](#synopsis)
Run commands and profiles declared in /wtm/run.toml — long-running services and one-shot tasks.
Vocabulary: job the unit wtm runs; its kind is service (long-running) or task (one-shot) profile a named, ordered group of jobs compose stack a job that runs `docker compose`; an isolated worktree gets its own compose project shared service a job with scope = "shared": one instance for the repository, run in the main checkout; a worktree holding it reports it as joined namespace a worktree's own part of a shared service — a database, a realm named URL the address the run proxy serves () port URL the job's own port (), printed with --raw isolation isolated: the worktree gets its own ports, compose project and namespaces; verbatim: it keeps its source's values, and so shares its source's data touches the services whose data a task changes (a migration, a reset, a seed) foreign data data this worktree does not own: its source's when it is verbatim, every worktree's for a shared service with no namespace; a job whose touches reach it is refused unless --force \[worktree] a worktree's branch name, never a path; omitted, the current worktree
```plaintext
wtm run [flags]
```
### Examples
[Section titled “Examples”](#examples)
```plaintext
# Once per repository: detect compose files and package scripts
wtm run init
# Start the default profile in this worktree
wtm run up
# What runs, across every repository
wtm run ps
wtm run down
```
### Options
[Section titled “Options”](#options)
```plaintext
-h, --help help for run
```
### Options inherited from parent commands
[Section titled “Options inherited from parent commands”](#options-inherited-from-parent-commands)
```plaintext
-q, --quiet Silence human output; errors and the exit code are unaffected, and --output json still emits its document
```
### SEE ALSO
[Section titled “SEE ALSO”](#see-also)
* [wtm](/0-28/reference/wtm/) - Orchestrate git worktrees and team dev workflows from the terminal
* [wtm run addressing](/0-28/reference/wtm-run-addressing/) - Switch how the .env files spell a job's address
* [wtm run daemon](/0-28/reference/wtm-run-daemon/) - Inspect, stop or restart the process that runs the jobs
* [wtm run down](/0-28/reference/wtm-run-down/) - Stop a worktree's running jobs
* [wtm run export](/0-28/reference/wtm-run-export/) - Export run.toml as JSON on stdout
* [wtm run import](/0-28/reference/wtm-run-import/) - Replace run.toml with a JSON run config
* [wtm run init](/0-28/reference/wtm-run-init/) - Configure the run module (services & tasks) for this repo
* [wtm run job](/0-28/reference/wtm-run-job/) - Add, remove, or edit jobs in run.toml
* [wtm run list](/0-28/reference/wtm-run-list/) - List jobs and profiles declared in run.toml
* [wtm run logs](/0-28/reference/wtm-run-logs/) - Attach to a job's output
* [wtm run open](/0-28/reference/wtm-run-open/) - Open a job's URL in the browser
* [wtm run profile](/0-28/reference/wtm-run-profile/) - Add, remove, or edit profiles in run.toml
* [wtm run proxy](/0-28/reference/wtm-run-proxy/) - Inspect and install the redirection that serves named URLs on port 80
* [wtm run ps](/0-28/reference/wtm-run-ps/) - List currently running jobs
* [wtm run start](/0-28/reference/wtm-run-start/) - Start a single job
* [wtm run stop](/0-28/reference/wtm-run-stop/) - Stop one job, in one or more worktrees
* [wtm run up](/0-28/reference/wtm-run-up/) - Start a profile's jobs
* [wtm run url](/0-28/reference/wtm-run-url/) - Print where a job is reachable in a worktree
# wtm run addressing
Switch how the .env files spell a job's address
### Synopsis
[Section titled “Synopsis”](#synopsis)
Set run.toml's addressing — named URLs () or port URLs () — then settle the .env of the worktrees that spell the other one. Settling runs even when the mode is already the one given, for a worktree an earlier switch left out of step.
The main checkout is settled back to ports, never onto names: it is the checkout that works without wtm, and `wtm env main` is how it is moved onto names.
Without an argument, prompts for the mode; under --yes the argument is required and the worktrees are settled unless --keep-env is passed.
```plaintext
wtm run addressing [names|ports] [flags]
```
### Examples
[Section titled “Examples”](#examples)
```plaintext
# Pick the mode
wtm run addressing
wtm run addressing ports --yes
# Switch run.toml only, leaving the .env files as they are
wtm run addressing names --yes --keep-env
```
### Options
[Section titled “Options”](#options)
```plaintext
-h, --help help for addressing
--keep-env Switch run.toml only, leaving the worktrees' .env files as they are
--output string Output format: text or json (default "text")
-y, --yes Skip the prompts; [names|ports] is then required
```
### Options inherited from parent commands
[Section titled “Options inherited from parent commands”](#options-inherited-from-parent-commands)
```plaintext
-q, --quiet Silence human output; errors and the exit code are unaffected, and --output json still emits its document
```
### SEE ALSO
[Section titled “SEE ALSO”](#see-also)
* [wtm run](/0-28/reference/wtm-run/) - Manage dev jobs (services + tasks)
# wtm run daemon
Inspect, stop or restart the process that runs the jobs
### Synopsis
[Section titled “Synopsis”](#synopsis)
Jobs are started by a background daemon shared by every repository. It exits on its own once no foreground job is left; detached services keep running without it and are picked back up by the next one.
```plaintext
wtm run daemon [flags]
```
### Examples
[Section titled “Examples”](#examples)
```plaintext
wtm run daemon status
wtm run daemon restart
```
### Options
[Section titled “Options”](#options)
```plaintext
-h, --help help for daemon
```
### Options inherited from parent commands
[Section titled “Options inherited from parent commands”](#options-inherited-from-parent-commands)
```plaintext
-q, --quiet Silence human output; errors and the exit code are unaffected, and --output json still emits its document
```
### SEE ALSO
[Section titled “SEE ALSO”](#see-also)
* [wtm run](/0-28/reference/wtm-run/) - Manage dev jobs (services + tasks)
* [wtm run daemon restart](/0-28/reference/wtm-run-daemon-restart/) - Hand the jobs over to a daemon built from this binary
* [wtm run daemon status](/0-28/reference/wtm-run-daemon-status/) - Report whether a daemon is running, and which build it is
* [wtm run daemon stop](/0-28/reference/wtm-run-daemon-stop/) - Stop the daemon, leaving detached services running
# wtm run daemon restart
Hand the jobs over to a daemon built from this binary
### Synopsis
[Section titled “Synopsis”](#synopsis)
Stop the running daemon and start one from this binary. This is the way out of a version mismatch: the daemon is what runs the jobs, so an older one keeps serving its own behavior until it is replaced. Detached services keep running across the restart and are picked back up; foreground ones are stopped.
```plaintext
wtm run daemon restart [flags]
```
### Examples
[Section titled “Examples”](#examples)
```plaintext
# After an upgrade, when a run command reports the daemon's version
wtm run daemon restart
wtm run daemon restart --yes
```
### Options
[Section titled “Options”](#options)
```plaintext
-h, --help help for restart
--output string Output format: text or json (default "text")
-y, --yes Skip the confirmation
```
### Options inherited from parent commands
[Section titled “Options inherited from parent commands”](#options-inherited-from-parent-commands)
```plaintext
-q, --quiet Silence human output; errors and the exit code are unaffected, and --output json still emits its document
```
### SEE ALSO
[Section titled “SEE ALSO”](#see-also)
* [wtm run daemon](/0-28/reference/wtm-run-daemon/) - Inspect, stop or restart the process that runs the jobs
# wtm run daemon status
Report whether a daemon is running, and which build it is
```plaintext
wtm run daemon status [flags]
```
### Examples
[Section titled “Examples”](#examples)
```plaintext
wtm run daemon status
wtm run daemon status --output json
```
### Options
[Section titled “Options”](#options)
```plaintext
-h, --help help for status
--output string Output format: text or json (default "text")
```
### Options inherited from parent commands
[Section titled “Options inherited from parent commands”](#options-inherited-from-parent-commands)
```plaintext
-q, --quiet Silence human output; errors and the exit code are unaffected, and --output json still emits its document
```
### SEE ALSO
[Section titled “SEE ALSO”](#see-also)
* [wtm run daemon](/0-28/reference/wtm-run-daemon/) - Inspect, stop or restart the process that runs the jobs
# wtm run daemon stop
Stop the daemon, leaving detached services running
### Synopsis
[Section titled “Synopsis”](#synopsis)
Stop the background daemon. Foreground services die with it — they are drained through a terminal it owns. Detached services (those with a stop command) keep running, and the next daemon picks them back up.
```plaintext
wtm run daemon stop [flags]
```
### Examples
[Section titled “Examples”](#examples)
```plaintext
wtm run daemon stop
wtm run daemon stop --yes
```
### Options
[Section titled “Options”](#options)
```plaintext
-h, --help help for stop
--output string Output format: text or json (default "text")
-y, --yes Skip the confirmation
```
### Options inherited from parent commands
[Section titled “Options inherited from parent commands”](#options-inherited-from-parent-commands)
```plaintext
-q, --quiet Silence human output; errors and the exit code are unaffected, and --output json still emits its document
```
### SEE ALSO
[Section titled “SEE ALSO”](#see-also)
* [wtm run daemon](/0-28/reference/wtm-run-daemon/) - Inspect, stop or restart the process that runs the jobs
# wtm run down
Stop a worktree's running jobs
### Synopsis
[Section titled “Synopsis”](#synopsis)
Stop the jobs running in \[worktree] — the current one when omitted, picked interactively when there is a terminal. With --profile, stops only that profile's jobs. Jobs running in other worktrees are never touched, unless --all is given: it stops every worktree of this repository, without asking, and lists each one it emptied. Other repositories are never touched.
```plaintext
wtm run down [worktree...] [flags]
```
### Examples
[Section titled “Examples”](#examples)
```plaintext
wtm run down
wtm run down feat/login --profile backend
# Every worktree of this repository
wtm run down --all --yes
```
### Options
[Section titled “Options”](#options)
```plaintext
--all Stop the jobs of every worktree of this repository
-h, --help help for down
--output string Output format: text or json (default "text")
--profile string Stop only this profile's jobs (default: every job the worktree runs)
-y, --yes Skip all prompts; stops what the worktree has running
```
### Options inherited from parent commands
[Section titled “Options inherited from parent commands”](#options-inherited-from-parent-commands)
```plaintext
-q, --quiet Silence human output; errors and the exit code are unaffected, and --output json still emits its document
```
### SEE ALSO
[Section titled “SEE ALSO”](#see-also)
* [wtm run](/0-28/reference/wtm-run/) - Manage dev jobs (services + tasks)
# wtm run export
Export run.toml as JSON on stdout
### Synopsis
[Section titled “Synopsis”](#synopsis)
Emit the current run config as JSON on stdout, whatever --output says: like run url, this is machine output and is never framed. Pipe to a file and use with wtm run import to share configurations.
```plaintext
wtm run export [flags]
```
### Examples
[Section titled “Examples”](#examples)
```plaintext
wtm run export > run.json
# One profile and its jobs
wtm run export --profile backend > backend.json
# Copy the layout into another clone
wtm run export | (cd ../other-clone && wtm run import - --yes)
```
### Options
[Section titled “Options”](#options)
```plaintext
-h, --help help for export
--output string Output format: text or json (default "text")
--profile string Export only this profile and its jobs
```
### Options inherited from parent commands
[Section titled “Options inherited from parent commands”](#options-inherited-from-parent-commands)
```plaintext
-q, --quiet Silence human output; errors and the exit code are unaffected, and --output json still emits its document
```
### SEE ALSO
[Section titled “SEE ALSO”](#see-also)
* [wtm run](/0-28/reference/wtm-run/) - Manage dev jobs (services + tasks)
# wtm run import
Replace run.toml with a JSON run config
### Synopsis
[Section titled “Synopsis”](#synopsis)
Read a JSON run config payload from a file (or stdin) and make it the run.toml.
Pass "-" or omit the argument to read from stdin.
The payload replaces the whole file — jobs, profiles, .env port links and project settings alike. The run is confirmed before anything is written; pass --yes to run unattended.
Nothing is reconciled after the write: run wtm env to settle the .env files against the new configuration.
```plaintext
wtm run import [file] [flags]
```
### Examples
[Section titled “Examples”](#examples)
```plaintext
wtm run import run.json
# No confirmation, from stdin
cat run.json | wtm run import - --yes
# Then settle a worktree's .env files on it
wtm env feat/login --yes
```
### Options
[Section titled “Options”](#options)
```plaintext
-h, --help help for import
--output string Output format: text or json (default "text")
-y, --yes Replace run.toml without confirming
```
### Options inherited from parent commands
[Section titled “Options inherited from parent commands”](#options-inherited-from-parent-commands)
```plaintext
-q, --quiet Silence human output; errors and the exit code are unaffected, and --output json still emits its document
```
### SEE ALSO
[Section titled “SEE ALSO”](#see-also)
* [wtm run](/0-28/reference/wtm-run/) - Manage dev jobs (services + tasks)
# wtm run init
Configure the run module (services & tasks) for this repo
### Synopsis
[Section titled “Synopsis”](#synopsis)
Set up run.toml by detecting docker-compose files and package.json scripts and turning the selected ones into jobs.
In a TTY, opens a wizard to pick which ones to include; with --yes (or piped), auto-generates from detection. Re-running pre-fills every step from the existing run.toml: what stays checked is kept, what you uncheck is removed along with the profile entries and .env links naming it. Only jobs this wizard proposed are ever removed — one added with `wtm run job add` is never listed, so never touched. An unattended run asks nothing and removes nothing.
Ports declared in the selected compose files become per-worktree ports. A literal host port ("5432:5432") binds the same port everywhere, so wtm offers to rewrite it as "${DB_PORT:-5432}:5432" — the default keeps `docker compose up` working on its own. Declining leaves the file untouched and declares no port for it.
The names those files pin absolutely get the same treatment. A container_name, or a volume's or network's explicit name, is resolved by the Docker daemon rather than by the compose project, so COMPOSE_PROJECT_NAME never reaches it and a second worktree collides on it. wtm offers to front them with the project — a renamed volume starts empty, its data staying under the name it used to carry.
In a monorepo, a root script that starts several apps at once is asked which declared jobs it runs. wtm reads the directory a script sits in, never what its command does: the relation is declared, and it is what keeps a runner from being reported as a service that forgot its port — and from being started alongside one of its own children.
Dev servers get theirs from the env files sitting next to their package.json — a PORT (or \*\_PORT) entry in .env.local, .env, or a committed .env.example. A service nothing was found for is offered anyway: declaring its port is what keeps a second worktree from binding the same one.
wtm injects the variable, it never edits the command. When a command never mentions the port it is given, the wizard offers it for editing on the spot (`pnpm dev --port ${PORT}`) rather than reporting it once it is too late.
The mode those names are written in is asked too, because it is the one choice with a consequence outside wtm: named URLs are served by the run proxy, so they answer while `wtm run` runs the job and not when you start it yourself. A project whose author launches their own dev servers wants ports.
Every service that declares the port it listens on is then offered a name of its own — ...localhost, served by the proxy — so two worktrees stop sharing a cookie jar. A port a job only dials (DB_PORT, REDIS_PORT) is never offered: a name nothing answers under is worse than no name at all.
```plaintext
wtm run init [flags]
```
### Examples
[Section titled “Examples”](#examples)
```plaintext
# The wizard
wtm run init
# Unattended, from detection
wtm run init --yes
# Also rewrite compose host ports and link the .env port keys
wtm run init --yes --patch-compose --link-env
```
### Options
[Section titled “Options”](#options)
```plaintext
-h, --help help for init
--link-env Link the .env keys holding a declared port, so each worktree gets its own
--patch-compose Rewrite the selected compose files' literal host ports and absolute names to read a variable
--write-port-keys Write each declared port into the job's .env and its template, so an app launched by hand reads the worktree's port
-y, --yes Run unattended: auto-generate from detection; never prompt
```
### Options inherited from parent commands
[Section titled “Options inherited from parent commands”](#options-inherited-from-parent-commands)
```plaintext
-q, --quiet Silence human output; errors and the exit code are unaffected, and --output json still emits its document
```
### SEE ALSO
[Section titled “SEE ALSO”](#see-also)
* [wtm run](/0-28/reference/wtm-run/) - Manage dev jobs (services + tasks)
# wtm run job
Add, remove, or edit jobs in run.toml
### Synopsis
[Section titled “Synopsis”](#synopsis)
Manage jobs declared in /wtm/run.toml.
```plaintext
wtm run job [flags]
```
### Examples
[Section titled “Examples”](#examples)
```plaintext
wtm run job list
wtm run job add web --cmd 'pnpm dev --port ${PORT}' --cwd apps/web --port PORT=3000 --url-port PORT --yes
wtm run job edit web
wtm run job rm web
```
### Options
[Section titled “Options”](#options)
```plaintext
-h, --help help for job
```
### Options inherited from parent commands
[Section titled “Options inherited from parent commands”](#options-inherited-from-parent-commands)
```plaintext
-q, --quiet Silence human output; errors and the exit code are unaffected, and --output json still emits its document
```
### SEE ALSO
[Section titled “SEE ALSO”](#see-also)
* [wtm run](/0-28/reference/wtm-run/) - Manage dev jobs (services + tasks)
* [wtm run job add](/0-28/reference/wtm-run-job-add/) - Add a job to run.toml
* [wtm run job edit](/0-28/reference/wtm-run-job-edit/) - Edit an existing job
* [wtm run job list](/0-28/reference/wtm-run-job-list/) - List jobs from run.toml
* [wtm run job rm](/0-28/reference/wtm-run-job-rm/) - Remove a job from run.toml
# wtm run job add
Add a job to run.toml
### Synopsis
[Section titled “Synopsis”](#synopsis)
Append a job to /wtm/run.toml.
Every flag pre-fills the corresponding question, so the form opens on what was already given. --yes skips the questions altogether: \[name] and --cmd are then required, and every other field falls back to its documented default.
\--runs, --touches and --binds-no-port declare how the job relates to the others; --scope shared and the --namespace-\* flags declare a service run once for the whole repository and each worktree's namespace in it. The file is refused exactly as loading it would refuse it: a namespace only on a shared service, with both a name and a create command; --runs and --touches naming declared jobs.
\--cmd and --stop are /bin/sh lines: quotes, && and ${VAR} behave as in a terminal, so a declared port can be passed as a flag — --cmd 'pnpm dev --port ${PORT}'.
```plaintext
wtm run job add [name] [flags]
```
### Examples
[Section titled “Examples”](#examples)
```plaintext
# Answer the form
wtm run job add
# A dev server with its own port per worktree and a named URL
wtm run job add web --cmd 'pnpm dev --port ${PORT}' --cwd apps/web --port PORT=3000 --url-port PORT --yes
# A migration, which changes the data of the postgres job
wtm run job add migrate --kind task --cmd 'pnpm db:migrate' --touches postgres --yes
# One postgres for the repository, a database per worktree
wtm run job add postgres --cmd 'docker compose up -d postgres' --stop 'docker compose stop postgres' \
--scope shared --port POSTGRES_PORT=5432 --namespace-name 'app_{worktree}' \
--namespace-create scripts/db-add.sh --namespace-remove scripts/db-drop.sh --yes
```
### Options
[Section titled “Options”](#options)
```plaintext
--binds-no-port This service listens on nothing by design, so stop offering it a port
--cmd string Command to run, as a /bin/sh line
--cwd string Working directory (relative to project root)
-h, --help help for add
--kind string Job kind: service or task (default "service")
--namespace-create string Command creating the namespace, run on every start of the shared service (must be safe to rerun)
--namespace-env stringArray Extra variable for the namespace commands as KEY=VALUE, repeatable ({worktree} and {ordinal} are filled in)
--namespace-name string Name of each worktree's namespace in a shared service, e.g. app_{worktree}
--namespace-remove string Command dropping the namespace, run by wtm clean
--output string Output format: text or json (default "text")
--port stringArray Base port as NAME=PORT, repeatable (e.g. --port PORT=3000)
--runs stringArray Declared job this one starts itself, repeatable (a turbo or compose runner)
--scope string Where the job runs: shared (one instance for the whole repository) or worktree (the default, one per worktree)
--stop string Stop command, as a /bin/sh line (services only)
--touches stringArray Declared service whose data this job changes (a migration, a reset, a seed), repeatable
--url-host string Host segment to publish under, defaulting to the job's name
--url-port string Publish this declared port under a name (e.g. --url-port PORT)
-y, --yes Skip all prompts; [name] and --cmd are then required
```
### Options inherited from parent commands
[Section titled “Options inherited from parent commands”](#options-inherited-from-parent-commands)
```plaintext
-q, --quiet Silence human output; errors and the exit code are unaffected, and --output json still emits its document
```
### SEE ALSO
[Section titled “SEE ALSO”](#see-also)
* [wtm run job](/0-28/reference/wtm-run-job/) - Add, remove, or edit jobs in run.toml
# wtm run job edit
Edit an existing job
### Synopsis
[Section titled “Synopsis”](#synopsis)
Edit a job declared in /wtm/run.toml.
Pass any of --name, --cmd, --kind, --stop, --cwd, --port, --port-clear, --url-port, --url-host, --runs, --binds-no-port, --touches, --scope, --namespace-name, --namespace-create, --namespace-remove or --namespace-env to change those fields and nothing else: a flag left out keeps the field as it is, and passing an empty string clears it (--stop '' drops the stop command, --url-port '' withdraws the published name, --namespace-name '' withdraws the whole \[job.namespace]).
\--scope shared runs one instance for the whole repository, in the main checkout; --scope worktree puts it back to one per worktree. A \[job.namespace] is only accepted on a shared service and needs both a name and a create command, which runs on every start and so must be safe to run again. --runs and --touches name declared jobs; the file is refused exactly as loading it would refuse it.
\--port merges into the ports the job already declares, so one entry can be changed without rewriting the others; --port-clear empties the table. --name also rewrites what names this job elsewhere in the file: the profiles, the runners' runs, the touches, and the \[\[env_port]] and \[\[env]] links. It is refused while a worktree holds data in the job, which clean finds by its name.
With no such flag, the form opens pre-filled with the current values, and without an argument it prompts to pick from the existing jobs.
```plaintext
wtm run job edit [name] [flags]
```
### Examples
[Section titled “Examples”](#examples)
```plaintext
# The form, pre-filled
wtm run job edit web
wtm run job edit web --cmd 'pnpm dev --port ${PORT}' --yes
# Change one port, keep the others
wtm run job edit web --port PORT=3001 --yes
# Rename it everywhere run.toml names it
wtm run job edit web --name frontend --yes
```
### Options
[Section titled “Options”](#options)
```plaintext
--binds-no-port This service listens on nothing by design, so stop offering it a port (--binds-no-port=false to undo)
--cmd string Command to run, as a /bin/sh line
--cwd string Working directory relative to project root (pass '' to drop it)
-h, --help help for edit
--kind string Job kind: service or task
--name string Rename the job, updating the profiles, runs, touches, [[env_port]] and [[env]] links that name it
--namespace-create string Command creating the namespace, run on every start of the shared service (must be safe to rerun)
--namespace-env stringArray Extra variable for the namespace commands as KEY=VALUE, repeatable — replaces the table (pass '' to drop it)
--namespace-name string Name of each worktree's namespace in a shared service (pass '' to withdraw the whole [job.namespace])
--namespace-remove string Command dropping the namespace, run by wtm clean (pass '' to drop it)
--output string Output format: text or json (default "text")
--port stringArray Base port as NAME=PORT, repeatable — merged into the declared ports
--port-clear Drop every port this job declares
--runs stringArray Declared job this one starts itself, repeatable — replaces the list (pass '' to drop it)
--scope string Where the job runs: shared (one instance for the whole repository) or worktree (one per worktree)
--stop string Stop command, as a /bin/sh line (pass '' to drop it)
--touches stringArray Declared service whose data this job changes (a migration, a reset, a seed), repeatable — replaces the list (pass '' to drop it)
--url-host string Host segment to publish under (pass '' to fall back to the job's name)
--url-port string Publish this declared port under a name (pass '' to withdraw the named URL)
-y, --yes Skip all prompts; a field flag is then required
```
### Options inherited from parent commands
[Section titled “Options inherited from parent commands”](#options-inherited-from-parent-commands)
```plaintext
-q, --quiet Silence human output; errors and the exit code are unaffected, and --output json still emits its document
```
### SEE ALSO
[Section titled “SEE ALSO”](#see-also)
* [wtm run job](/0-28/reference/wtm-run-job/) - Add, remove, or edit jobs in run.toml
# wtm run job list
List jobs from run.toml
### Synopsis
[Section titled “Synopsis”](#synopsis)
List jobs declared in /wtm/run.toml.
In a TTY, opens an interactive picker. Selecting a job offers Edit or Remove. Use --output json, --yes (or pipe stdout) for a non-interactive listing.
```plaintext
wtm run job list [flags]
```
### Examples
[Section titled “Examples”](#examples)
```plaintext
wtm run job list
wtm run job list --output json
```
### Options
[Section titled “Options”](#options)
```plaintext
-h, --help help for list
--output string Output format: text or json (default "text")
-y, --yes Skip the picker; print the table instead
```
### Options inherited from parent commands
[Section titled “Options inherited from parent commands”](#options-inherited-from-parent-commands)
```plaintext
-q, --quiet Silence human output; errors and the exit code are unaffected, and --output json still emits its document
```
### SEE ALSO
[Section titled “SEE ALSO”](#see-also)
* [wtm run job](/0-28/reference/wtm-run-job/) - Add, remove, or edit jobs in run.toml
# wtm run job rm
Remove a job from run.toml
### Synopsis
[Section titled “Synopsis”](#synopsis)
Remove a job from /wtm/run.toml.
Without an argument, prompts to pick from the existing jobs; under --yes the argument is required. Fails if anything names the job — a profile, a runner's runs, a job's touches, an \[\[env_port]] or an \[\[env]] link — or if a worktree still holds data in it (a shared service's namespace, which clean finds by the job's name), unless --force is given: the references are then stripped, and that data is left for you to drop by hand.
```plaintext
wtm run job rm [name] [flags]
```
### Examples
[Section titled “Examples”](#examples)
```plaintext
# Pick the job
wtm run job rm
wtm run job rm worker --yes
# Also strip the profiles and links that name it
wtm run job rm postgres --force --yes
```
### Options
[Section titled “Options”](#options)
```plaintext
--force Remove it anyway: strip the profiles, runs, touches, [[env_port]] and [[env]] links naming it
-h, --help help for rm
--output string Output format: text or json (default "text")
-y, --yes Skip the picker; [name] is then required
```
### Options inherited from parent commands
[Section titled “Options inherited from parent commands”](#options-inherited-from-parent-commands)
```plaintext
-q, --quiet Silence human output; errors and the exit code are unaffected, and --output json still emits its document
```
### SEE ALSO
[Section titled “SEE ALSO”](#see-also)
* [wtm run job](/0-28/reference/wtm-run-job/) - Add, remove, or edit jobs in run.toml
# wtm run list
List jobs and profiles declared in run.toml
### Synopsis
[Section titled “Synopsis”](#synopsis)
Show the jobs and profiles configured for the project. In a TTY, offers an interactive picker with start/stop/logs actions.
```plaintext
wtm run list [flags]
```
### Examples
[Section titled “Examples”](#examples)
```plaintext
# Pick a job or a profile, then start, stop or read it
wtm run list
# Print the table
wtm run list --yes
wtm run list --output json
```
### Options
[Section titled “Options”](#options)
```plaintext
-h, --help help for list
--output string Output format: text or json (default "text")
-y, --yes Skip the interactive picker; print the table instead
```
### Options inherited from parent commands
[Section titled “Options inherited from parent commands”](#options-inherited-from-parent-commands)
```plaintext
-q, --quiet Silence human output; errors and the exit code are unaffected, and --output json still emits its document
```
### SEE ALSO
[Section titled “SEE ALSO”](#see-also)
* [wtm run](/0-28/reference/wtm-run/) - Manage dev jobs (services + tasks)
# wtm run logs
Attach to a job's output
### Synopsis
[Section titled “Synopsis”](#synopsis)
Open the run view on \[worktree]'s jobs — the current worktree when omitted, picked interactively when there is a terminal. --job focuses one of them; without it, every job is shown. Leaving the view detaches; the jobs keep running. Without a terminal, every job's output is written as prefixed lines instead. --output json replays each job's last 1000 lines as \[{branch, path, lines: \[{job, at, text}]}], one entry per worktree, and never attaches.
```plaintext
wtm run logs [worktree...] [flags]
```
### Examples
[Section titled “Examples”](#examples)
```plaintext
# Reopen the run view on this worktree's jobs
wtm run logs
wtm run logs feat/login --job api
# The last 1000 lines of each job, as JSON
wtm run logs feat/login --output json
```
### Options
[Section titled “Options”](#options)
```plaintext
-h, --help help for logs
--job string Focus a single job instead of showing them all
--output string Output format: text or json (default "text")
-y, --yes Skip all prompts; shows every job of the current worktree
```
### Options inherited from parent commands
[Section titled “Options inherited from parent commands”](#options-inherited-from-parent-commands)
```plaintext
-q, --quiet Silence human output; errors and the exit code are unaffected, and --output json still emits its document
```
### SEE ALSO
[Section titled “SEE ALSO”](#see-also)
* [wtm run](/0-28/reference/wtm-run/) - Manage dev jobs (services + tasks)
# wtm run open
Open a job's URL in the browser
### Synopsis
[Section titled “Synopsis”](#synopsis)
Hand a job's URL to the desktop's own opener. \[worktree] defaults to the current one, and is picked interactively when there is a terminal. A worktree publishing one URL opens it; when several jobs publish one, --job names it, and is required outside a fully interactive run — a picker never runs under a pipe, under --yes or in --output json mode.
```plaintext
wtm run open [worktree] [flags]
```
### Examples
[Section titled “Examples”](#examples)
```plaintext
wtm run open
wtm run open feat/login --job web
# The port URL instead of the named one
wtm run open feat/login --job web --raw
```
### Options
[Section titled “Options”](#options)
```plaintext
-h, --help help for open
--job string Job whose URL to open (required when several jobs publish one, outside a fully interactive run)
--output string Output format: text or json (default "text")
--raw Open the port URL (http://localhost:) instead of the named URL
-y, --yes Skip the pickers; --job is then required when several jobs publish a URL
```
### Options inherited from parent commands
[Section titled “Options inherited from parent commands”](#options-inherited-from-parent-commands)
```plaintext
-q, --quiet Silence human output; errors and the exit code are unaffected, and --output json still emits its document
```
### SEE ALSO
[Section titled “SEE ALSO”](#see-also)
* [wtm run](/0-28/reference/wtm-run/) - Manage dev jobs (services + tasks)
# wtm run profile
Add, remove, or edit profiles in run.toml
### Synopsis
[Section titled “Synopsis”](#synopsis)
Manage profiles declared in /wtm/run.toml.
```plaintext
wtm run profile [flags]
```
### Examples
[Section titled “Examples”](#examples)
```plaintext
wtm run profile list
wtm run profile add backend --jobs postgres,migrate,api --yes
wtm run profile edit backend --default --yes
wtm run profile rm backend
```
### Options
[Section titled “Options”](#options)
```plaintext
-h, --help help for profile
```
### Options inherited from parent commands
[Section titled “Options inherited from parent commands”](#options-inherited-from-parent-commands)
```plaintext
-q, --quiet Silence human output; errors and the exit code are unaffected, and --output json still emits its document
```
### SEE ALSO
[Section titled “SEE ALSO”](#see-also)
* [wtm run](/0-28/reference/wtm-run/) - Manage dev jobs (services + tasks)
* [wtm run profile add](/0-28/reference/wtm-run-profile-add/) - Add a profile to run.toml
* [wtm run profile edit](/0-28/reference/wtm-run-profile-edit/) - Edit an existing profile
* [wtm run profile list](/0-28/reference/wtm-run-profile-list/) - List profiles from run.toml
* [wtm run profile rm](/0-28/reference/wtm-run-profile-rm/) - Remove a profile from run.toml
# wtm run profile add
Add a profile to run.toml
### Synopsis
[Section titled “Synopsis”](#synopsis)
Append a profile to /wtm/run.toml.
Every flag pre-fills the corresponding question, so the form opens on what was already given. --yes skips the questions altogether: \[name] and --jobs are then required, and the profile is not the default unless --default says so.
```plaintext
wtm run profile add [name] [flags]
```
### Examples
[Section titled “Examples”](#examples)
```plaintext
# Answer the form
wtm run profile add
wtm run profile add backend --jobs postgres,migrate,api --yes
# What run up starts without --profile
wtm run profile add full --jobs postgres,api,web --default --yes
```
### Options
[Section titled “Options”](#options)
```plaintext
--default Mark this profile as the default
-h, --help help for add
--jobs strings Comma-separated existing job names, in start order
--output string Output format: text or json (default "text")
-y, --yes Skip all prompts; [name] and --jobs are then required
```
### Options inherited from parent commands
[Section titled “Options inherited from parent commands”](#options-inherited-from-parent-commands)
```plaintext
-q, --quiet Silence human output; errors and the exit code are unaffected, and --output json still emits its document
```
### SEE ALSO
[Section titled “SEE ALSO”](#see-also)
* [wtm run profile](/0-28/reference/wtm-run-profile/) - Add, remove, or edit profiles in run.toml
# wtm run profile edit
Edit an existing profile
### Synopsis
[Section titled “Synopsis”](#synopsis)
Edit a profile declared in /wtm/run.toml.
Pass --name, --jobs or --default to change those fields and nothing else: a flag left out keeps the field as it is. --jobs replaces the whole list — its order is the start order, so it is given in full — and --default=false takes the default away without handing it to another profile.
With no such flag, the form opens pre-filled with the current values, and without an argument it prompts to pick from the existing profiles.
```plaintext
wtm run profile edit [name] [flags]
```
### Examples
[Section titled “Examples”](#examples)
```plaintext
# The form, pre-filled
wtm run profile edit backend
# --jobs replaces the list, in start order
wtm run profile edit backend --jobs postgres,api --yes
wtm run profile edit backend --default --yes
```
### Options
[Section titled “Options”](#options)
```plaintext
--default Mark this profile as the default (--default=false takes it away)
-h, --help help for edit
--jobs strings Comma-separated existing job names, in start order (replaces the list)
--name string Rename the profile
--output string Output format: text or json (default "text")
-y, --yes Skip all prompts; a field flag is then required
```
### Options inherited from parent commands
[Section titled “Options inherited from parent commands”](#options-inherited-from-parent-commands)
```plaintext
-q, --quiet Silence human output; errors and the exit code are unaffected, and --output json still emits its document
```
### SEE ALSO
[Section titled “SEE ALSO”](#see-also)
* [wtm run profile](/0-28/reference/wtm-run-profile/) - Add, remove, or edit profiles in run.toml
# wtm run profile list
List profiles from run.toml
### Synopsis
[Section titled “Synopsis”](#synopsis)
List profiles declared in /wtm/run.toml.
In a TTY, opens an interactive picker. Selecting a profile offers Edit or Remove. Use --output json, --yes (or pipe stdout) for a non-interactive listing.
```plaintext
wtm run profile list [flags]
```
### Examples
[Section titled “Examples”](#examples)
```plaintext
wtm run profile list
wtm run profile list --output json
```
### Options
[Section titled “Options”](#options)
```plaintext
-h, --help help for list
--output string Output format: text or json (default "text")
-y, --yes Skip the picker; print the table instead
```
### Options inherited from parent commands
[Section titled “Options inherited from parent commands”](#options-inherited-from-parent-commands)
```plaintext
-q, --quiet Silence human output; errors and the exit code are unaffected, and --output json still emits its document
```
### SEE ALSO
[Section titled “SEE ALSO”](#see-also)
* [wtm run profile](/0-28/reference/wtm-run-profile/) - Add, remove, or edit profiles in run.toml
# wtm run profile rm
Remove a profile from run.toml
### Synopsis
[Section titled “Synopsis”](#synopsis)
Remove a profile from /wtm/run.toml.
Without an argument, prompts to pick from the existing profiles; under --yes the argument is required. Jobs referenced by the profile are left untouched.
```plaintext
wtm run profile rm [name] [flags]
```
### Examples
[Section titled “Examples”](#examples)
```plaintext
# Pick the profile
wtm run profile rm
wtm run profile rm backend --yes
```
### Options
[Section titled “Options”](#options)
```plaintext
-h, --help help for rm
--output string Output format: text or json (default "text")
-y, --yes Skip the picker; [name] is then required
```
### Options inherited from parent commands
[Section titled “Options inherited from parent commands”](#options-inherited-from-parent-commands)
```plaintext
-q, --quiet Silence human output; errors and the exit code are unaffected, and --output json still emits its document
```
### SEE ALSO
[Section titled “SEE ALSO”](#see-also)
* [wtm run profile](/0-28/reference/wtm-run-profile/) - Add, remove, or edit profiles in run.toml
# wtm run proxy
Inspect and install the redirection that serves named URLs on port 80
### Synopsis
[Section titled “Synopsis”](#synopsis)
Named job URLs carry the run proxy's port unless port 80 is redirected to it. These commands report that redirection and install or remove it.
```plaintext
wtm run proxy [flags]
```
### Examples
[Section titled “Examples”](#examples)
```plaintext
wtm run proxy status
wtm run proxy install
```
### Options
[Section titled “Options”](#options)
```plaintext
-h, --help help for proxy
```
### Options inherited from parent commands
[Section titled “Options inherited from parent commands”](#options-inherited-from-parent-commands)
```plaintext
-q, --quiet Silence human output; errors and the exit code are unaffected, and --output json still emits its document
```
### SEE ALSO
[Section titled “SEE ALSO”](#see-also)
* [wtm run](/0-28/reference/wtm-run/) - Manage dev jobs (services + tasks)
* [wtm run proxy install](/0-28/reference/wtm-run-proxy-install/) - Serve named URLs on port 80 so they drop their port
* [wtm run proxy status](/0-28/reference/wtm-run-proxy-status/) - Report what actually serves named URLs on this machine
* [wtm run proxy uninstall](/0-28/reference/wtm-run-proxy-uninstall/) - Remove the redirection and give named URLs their port back
# wtm run proxy install
Serve named URLs on port 80 so they drop their port
### Synopsis
[Section titled “Synopsis”](#synopsis)
macOS only. Install a per-user LaunchAgent: launchd binds port 80 on the loopback and hands the socket to wtm, which relays it to the run proxy. No sudo, no system file — everything lives in \~/Library/LaunchAgents and `wtm run proxy uninstall` removes it.
```plaintext
wtm run proxy install [flags]
```
### Examples
[Section titled “Examples”](#examples)
```plaintext
# See every file it would write
wtm run proxy install --dry-run
wtm run proxy install
wtm run proxy install --yes
```
### Options
[Section titled “Options”](#options)
```plaintext
--dry-run Print every file in full and write nothing
-h, --help help for install
--output string Output format: text or json (default "text")
-y, --yes Skip the confirmation
```
### Options inherited from parent commands
[Section titled “Options inherited from parent commands”](#options-inherited-from-parent-commands)
```plaintext
-q, --quiet Silence human output; errors and the exit code are unaffected, and --output json still emits its document
```
### SEE ALSO
[Section titled “SEE ALSO”](#see-also)
* [wtm run proxy](/0-28/reference/wtm-run-proxy/) - Inspect and install the redirection that serves named URLs on port 80
# wtm run proxy status
Report what actually serves named URLs on this machine
```plaintext
wtm run proxy status [flags]
```
### Examples
[Section titled “Examples”](#examples)
```plaintext
wtm run proxy status
wtm run proxy status --output json
```
### Options
[Section titled “Options”](#options)
```plaintext
-h, --help help for status
--output string Output format: text or json (default "text")
```
### Options inherited from parent commands
[Section titled “Options inherited from parent commands”](#options-inherited-from-parent-commands)
```plaintext
-q, --quiet Silence human output; errors and the exit code are unaffected, and --output json still emits its document
```
### SEE ALSO
[Section titled “SEE ALSO”](#see-also)
* [wtm run proxy](/0-28/reference/wtm-run-proxy/) - Inspect and install the redirection that serves named URLs on port 80
# wtm run proxy uninstall
Remove the redirection and give named URLs their port back
```plaintext
wtm run proxy uninstall [flags]
```
### Examples
[Section titled “Examples”](#examples)
```plaintext
wtm run proxy uninstall
wtm run proxy uninstall --yes
```
### Options
[Section titled “Options”](#options)
```plaintext
-h, --help help for uninstall
--output string Output format: text or json (default "text")
-y, --yes Skip the confirmation
```
### Options inherited from parent commands
[Section titled “Options inherited from parent commands”](#options-inherited-from-parent-commands)
```plaintext
-q, --quiet Silence human output; errors and the exit code are unaffected, and --output json still emits its document
```
### SEE ALSO
[Section titled “SEE ALSO”](#see-also)
* [wtm run proxy](/0-28/reference/wtm-run-proxy/) - Inspect and install the redirection that serves named URLs on port 80
# wtm run ps
List currently running jobs
### Synopsis
[Section titled “Synopsis”](#synopsis)
Show the jobs managed by the background daemon (name, kind, status, address, uptime, worktree). It lists every repository the daemon knows, so it works from anywhere — inside a run-initialized repository or not. To act on those jobs, open the run view with `wtm run logs`, which covers as many worktrees as you select.
```plaintext
wtm run ps [flags]
```
### Examples
[Section titled “Examples”](#examples)
```plaintext
wtm run ps
wtm run ps --output json
```
### Options
[Section titled “Options”](#options)
```plaintext
-h, --help help for ps
--output string Output format: text or json (default "text")
```
### Options inherited from parent commands
[Section titled “Options inherited from parent commands”](#options-inherited-from-parent-commands)
```plaintext
-q, --quiet Silence human output; errors and the exit code are unaffected, and --output json still emits its document
```
### SEE ALSO
[Section titled “SEE ALSO”](#see-also)
* [wtm run](/0-28/reference/wtm-run/) - Manage dev jobs (services + tasks)
# wtm run start
Start a single job
### Synopsis
[Section titled “Synopsis”](#synopsis)
Start one job of \[worktree] — the current one when omitted, picked interactively when there is a terminal. The job is named with --job; without it, a fully interactive run offers a picker. A service attaches: its output opens in the run view, and leaving the view detaches without stopping it. -d starts it and returns the prompt instead. A task always runs inline and blocks until it exits, with or without -d. Like `run up`, it reports what run.toml gets wrong before starting, checks the job's declared ports once it is up (see --no-probe and run.toml's port_probe_timeout), and asks once what to do about the jobs other worktrees are running; --exclusive and --parallel answer for one run.
```plaintext
wtm run start [worktree] [flags]
```
### Examples
[Section titled “Examples”](#examples)
```plaintext
# Pick the job to start in this worktree
wtm run start
wtm run start --job api
# A task runs inline, to the end
wtm run start feat/login --job migrate --yes
wtm run start feat/login --job api -d --yes --output json
```
### Options
[Section titled “Options”](#options)
```plaintext
-d, --detach Start the service and return immediately instead of opening its output
--exclusive Stop jobs on other worktrees before starting
--force Lift the refusal to start a job whose touches reach foreign data (see wtm run --help); other questions are still asked unless --yes
-h, --help help for start
--job string Job to start (required without a terminal or in --output json mode)
--no-probe Skip the check that each declared port was actually bound
--output string Output format: text or json (default "text")
--parallel Start without stopping other worktrees
-y, --yes Skip all prompts; --job is then required, and the other worktrees' jobs keep running unless --exclusive
```
### Options inherited from parent commands
[Section titled “Options inherited from parent commands”](#options-inherited-from-parent-commands)
```plaintext
-q, --quiet Silence human output; errors and the exit code are unaffected, and --output json still emits its document
```
### SEE ALSO
[Section titled “SEE ALSO”](#see-also)
* [wtm run](/0-28/reference/wtm-run/) - Manage dev jobs (services + tasks)
# wtm run stop
Stop one job, in one or more worktrees
### Synopsis
[Section titled “Synopsis”](#synopsis)
Stop one running job in each \[worktree] — the current one when omitted, picked interactively when there is a terminal. The job is named with --job; without it, a fully interactive run offers a picker.
```plaintext
wtm run stop [worktree...] [flags]
```
### Examples
[Section titled “Examples”](#examples)
```plaintext
wtm run stop --job api
wtm run stop feat/login fix/typo --job web --yes
```
### Options
[Section titled “Options”](#options)
```plaintext
-h, --help help for stop
--job string Job to stop (required without a terminal or in --output json mode)
--output string Output format: text or json (default "text")
-y, --yes Skip all prompts; --job is then required
```
### Options inherited from parent commands
[Section titled “Options inherited from parent commands”](#options-inherited-from-parent-commands)
```plaintext
-q, --quiet Silence human output; errors and the exit code are unaffected, and --output json still emits its document
```
### SEE ALSO
[Section titled “SEE ALSO”](#see-also)
* [wtm run](/0-28/reference/wtm-run/) - Manage dev jobs (services + tasks)
# wtm run up
Start a profile's jobs
### Synopsis
[Section titled “Synopsis”](#synopsis)
Start every job in a profile, in declared order, in each \[worktree] — the current one when omitted, picked interactively when there is a terminal. Several worktrees start concurrently and independently: one that aborts leaves the others running, and the run exits non-zero if any of them did. It starts one profile: --profile, else the default profile, else the only one declared. With several and none marked default it asks which, and fails naming --profile when it cannot ask. A run.toml declaring no profile starts every job. Once the jobs are up, each declared port is checked: a port nothing answers on is reported rather than announced as bound. It never fails the run — see --no-probe and run.toml's port_probe_timeout. Tasks block the profile and abort it on failure; services launch in the background. When another worktree is already running jobs, wtm asks once what to do about it and can remember the answer as run.toml's `concurrency`; --exclusive and --parallel override it for one run. --exclusive is refused on several worktrees, since it stops all but one. The run view opens on the jobs as they start; leaving it detaches without stopping them — the rest of the profile keeps starting, reported line by line — and -d skips the view.
```plaintext
wtm run up [worktree...] [flags]
```
### Examples
[Section titled “Examples”](#examples)
```plaintext
# The default profile, in this worktree, in the run view
wtm run up
# Two worktrees side by side, back to the prompt
wtm run up feat/login fix/typo -d
# Another profile, no prompts
wtm run up feat/login --profile backend -d --yes
# For a script or an agent
wtm run up feat/login -d --yes --output json
```
### Options
[Section titled “Options”](#options)
```plaintext
-d, --detach Start the jobs and return immediately instead of opening their output
--exclusive Stop jobs on other worktrees before starting (one worktree only)
--force Lift the refusal to start a job whose touches reach foreign data (see wtm run --help); other questions are still asked unless --yes
-h, --help help for up
--no-probe Skip the check that each declared port was actually bound
--output string Output format: text or json (default "text")
--parallel Start without stopping other worktrees
--profile string Start this profile's jobs (default: the profile marked default, or the only one declared)
-y, --yes Skip all prompts; leaves the other worktrees' jobs running unless --exclusive
```
### Options inherited from parent commands
[Section titled “Options inherited from parent commands”](#options-inherited-from-parent-commands)
```plaintext
-q, --quiet Silence human output; errors and the exit code are unaffected, and --output json still emits its document
```
### SEE ALSO
[Section titled “SEE ALSO”](#see-also)
* [wtm run](/0-28/reference/wtm-run/) - Manage dev jobs (services + tasks)
# wtm run url
Print where a job is reachable in a worktree
### Synopsis
[Section titled “Synopsis”](#synopsis)
Write a job's URL on stdout and nothing else, for $(…). \[worktree] defaults to the current one, and no picker ever opens here — an ambiguity is an error naming --job. The URL is the named URL the proxy serves (); --raw prints the port URL instead (:), which every OS resolves and no proxy has to serve.
```plaintext
wtm run url [worktree] [flags]
```
### Examples
[Section titled “Examples”](#examples)
```plaintext
wtm run url --job api
curl "$(wtm run url feat/login --job api)/health"
# The port URL, which needs no proxy
wtm run url feat/login --job api --raw
```
### Options
[Section titled “Options”](#options)
```plaintext
-h, --help help for url
--job string Job whose URL to print (required when several jobs publish one)
--output string Output format: text or json (default "text")
--raw Print the port URL (http://localhost:) instead of the named URL
```
### Options inherited from parent commands
[Section titled “Options inherited from parent commands”](#options-inherited-from-parent-commands)
```plaintext
-q, --quiet Silence human output; errors and the exit code are unaffected, and --output json still emits its document
```
### SEE ALSO
[Section titled “SEE ALSO”](#see-also)
* [wtm run](/0-28/reference/wtm-run/) - Manage dev jobs (services + tasks)
# wtm schema
Inspect or extract bundled JSON Schemas
### Synopsis
[Section titled “Synopsis”](#synopsis)
JSON Schemas describe the structure of wtm's TOML config files. Use `wtm schema dump` to write them to /wtm/schemas/ so editors can pick them up via the `#:schema` directive.
```plaintext
wtm schema [flags]
```
### Examples
[Section titled “Examples”](#examples)
```plaintext
wtm schema dump
wtm schema dump --global
```
### Options
[Section titled “Options”](#options)
```plaintext
-h, --help help for schema
```
### Options inherited from parent commands
[Section titled “Options inherited from parent commands”](#options-inherited-from-parent-commands)
```plaintext
-q, --quiet Silence human output; errors and the exit code are unaffected, and --output json still emits its document
```
### SEE ALSO
[Section titled “SEE ALSO”](#see-also)
* [wtm](/0-28/reference/wtm/) - Orchestrate git worktrees and team dev workflows from the terminal
* [wtm schema dump](/0-28/reference/wtm-schema-dump/) - Write embedded schemas to /schemas/ (or the global config's schemas/ with --global)
# wtm schema dump
Write embedded schemas to /schemas/ (or the global config's schemas/ with --global)
### Synopsis
[Section titled “Synopsis”](#synopsis)
Extract every JSON Schema bundled with this wtm binary so editors can resolve the `#:schema` directives in your TOML files. Project schemas land in /wtm/schemas/. Use --global to write the global schema next to the global wtm config, whose path `wtm run proxy status` prints.
```plaintext
wtm schema dump [flags]
```
### Examples
[Section titled “Examples”](#examples)
```plaintext
# The project schemas, beside config.toml and run.toml
wtm schema dump
# The global config's schema
wtm schema dump --global
```
### Options
[Section titled “Options”](#options)
```plaintext
--global Write the global config schema instead of the project ones
-h, --help help for dump
```
### Options inherited from parent commands
[Section titled “Options inherited from parent commands”](#options-inherited-from-parent-commands)
```plaintext
-q, --quiet Silence human output; errors and the exit code are unaffected, and --output json still emits its document
```
### SEE ALSO
[Section titled “SEE ALSO”](#see-also)
* [wtm schema](/0-28/reference/wtm-schema/) - Inspect or extract bundled JSON Schemas
# wtm shell-init
Generate shell integration function
### Synopsis
[Section titled “Synopsis”](#synopsis)
Output a shell function to eval in your rc file. Usage: eval "$(wtm shell-init)"
```plaintext
wtm shell-init [flags]
```
### Examples
[Section titled “Examples”](#examples)
```plaintext
# zsh or bash: add this line to ~/.zshrc or ~/.bashrc
eval "$(wtm shell-init)"
# fish: add this line to config.fish
wtm shell-init | source
```
### Options
[Section titled “Options”](#options)
```plaintext
-h, --help help for shell-init
```
### Options inherited from parent commands
[Section titled “Options inherited from parent commands”](#options-inherited-from-parent-commands)
```plaintext
-q, --quiet Silence human output; errors and the exit code are unaffected, and --output json still emits its document
```
### SEE ALSO
[Section titled “SEE ALSO”](#see-also)
* [wtm](/0-28/reference/wtm/) - Orchestrate git worktrees and team dev workflows from the terminal
# wtm sync
Rebase selected worktrees onto their parent, in cascade
### Synopsis
[Section titled “Synopsis”](#synopsis)
Rebase one or more managed worktrees onto their parent. Pass branch names to target specific worktrees, --all to sync every worktree, or no arguments to pick interactively. The base branch is fetched and fast-forwarded first, then each selected worktree is rebased onto its parent in topological order (parents before children). The cascade is local; on a conflict the branch is left clean (rebase aborted) and its selected descendants are skipped. Pass --keep-conflict to leave a conflicting rebase in progress in its worktree for manual resolution instead of aborting. A parent no step covers — a branch with no worktree, or one left out of the selection — is never refreshed by the cascade; when it is behind its remote you are offered to fast-forward it first (--ff-parents / --no-ff-parents). After a successful cascade, optionally force-push (with lease) the rebased branches.
```plaintext
wtm sync [branch...] [flags]
```
### Examples
[Section titled “Examples”](#examples)
```plaintext
# Pick the worktrees to rebase
wtm sync
# Preview the whole cascade
wtm sync --all --dry-run
# Rebase a stack, then force-push it (with lease)
wtm sync feat/login feat/login-ui --yes --push
# Every worktree, locally only
wtm sync --all --yes --no-push --output json
```
### Options
[Section titled “Options”](#options)
```plaintext
--all Sync every managed worktree
--base string Base branch to sync from (defaults to config or detected base)
--dry-run Preview the cascade without rebasing or pushing
--ff-parents Fast-forward the parents the cascade does not cover (no worktree, or left out of the selection) before rebasing onto them; no-op with --dry-run
-h, --help help for sync
--keep-conflict Leave a conflicting rebase in progress in its worktree instead of aborting
--no-ff-parents Never fast-forward those parents; rebase onto them as they are
--no-push Rebase locally only; never push
--output string Output format: text or json (default "text")
--push Force-push (with lease) rebased branches without prompting
-y, --yes Skip all prompts; resolve every decision from flags and safe defaults (requires branch args or --all; use --push to push)
```
### Options inherited from parent commands
[Section titled “Options inherited from parent commands”](#options-inherited-from-parent-commands)
```plaintext
-q, --quiet Silence human output; errors and the exit code are unaffected, and --output json still emits its document
```
### SEE ALSO
[Section titled “SEE ALSO”](#see-also)
* [wtm](/0-28/reference/wtm/) - Orchestrate git worktrees and team dev workflows from the terminal
# wtm tree
Show the worktree forest (parent → child)
### Synopsis
[Section titled “Synopsis”](#synopsis)
Render the forest of managed worktrees, parents above their children, with the orchestration signals that matter for a stacked-branch workflow: commits ahead (↑N), uncommitted changes (⚠ dirty), and "needs sync" when a parent has moved and the child must be rebased. Parents with no worktree appear as greyed virtual roots.
\--with-prs adds PR numbers and merged/closed markers (fetched eagerly). --output json emits the structured tree for agents; --output mermaid emits a flowchart to paste into a PR or Notion.
```plaintext
wtm tree [flags]
```
### Examples
[Section titled “Examples”](#examples)
```plaintext
wtm tree
# With PR numbers and merged/closed markers
wtm tree --with-prs
# A flowchart to paste into a PR description
wtm tree --output mermaid
```
### Options
[Section titled “Options”](#options)
```plaintext
-h, --help help for tree
--output string Output format: text, json or mermaid (default "text")
--with-prs Include GitHub PR info (open/merged/closed; fetched eagerly)
```
### Options inherited from parent commands
[Section titled “Options inherited from parent commands”](#options-inherited-from-parent-commands)
```plaintext
-q, --quiet Silence human output; errors and the exit code are unaffected, and --output json still emits its document
```
### SEE ALSO
[Section titled “SEE ALSO”](#see-also)
* [wtm](/0-28/reference/wtm/) - Orchestrate git worktrees and team dev workflows from the terminal
# wtm ui
Open the worktree dashboard
### Synopsis
[Section titled “Synopsis”](#synopsis)
Open a full-screen dashboard of the repository's worktrees. The Worktrees tab lists them with their git state against both the base branch and origin, and their pull requests; the Tree tab lays the same worktrees out as the parent-child forest `wtm tree` prints; the Services tab gathers every worktree the run daemon holds something up in, with the addresses its jobs answer on. `n` creates a worktree; right-click a row (or press `m`) to reparent, sync, or delete it; `a` opens the actions that run over several worktrees at once, syncing or reparenting a selection of them; `L` reads a job's logs in the detail panel. The list's local git state is re-read every 20 seconds, when the terminal regains focus and after each action; the detail panel reloads when the selection changes or an operation touches it, and pull requests load once. Nothing is fetched on its own: `r` fetches the remote and refreshes all of it. Press `?` for the key reference.
```plaintext
wtm ui [flags]
```
### Examples
[Section titled “Examples”](#examples)
```plaintext
# Press ? inside for the key reference
wtm ui
```
### Options
[Section titled “Options”](#options)
```plaintext
-h, --help help for ui
--output string Output format: text or json (default "text")
```
### Options inherited from parent commands
[Section titled “Options inherited from parent commands”](#options-inherited-from-parent-commands)
```plaintext
-q, --quiet Silence human output; errors and the exit code are unaffected, and --output json still emits its document
```
### SEE ALSO
[Section titled “SEE ALSO”](#see-also)
* [wtm](/0-28/reference/wtm/) - Orchestrate git worktrees and team dev workflows from the terminal
# wtm upgrade
Update wtm to the latest release
### Synopsis
[Section titled “Synopsis”](#synopsis)
Bring wtm up to the latest published release, doing the right thing for how it was installed. A standalone binary is replaced in place after its SHA256 is verified against the release checksums. A Homebrew or `go install` binary is handed to that tool instead — replacing a package-manager-owned binary would desynchronize it. A binary built from source is refused, since no published release corresponds to it.
This updates the CLI itself, not your worktrees — that is `wtm sync`.
\--check reports what is available without changing anything. --yes skips the confirmation (required with --output json). --version pins an explicit release and applies to standalone installs only.
```plaintext
wtm upgrade [flags]
```
### Examples
[Section titled “Examples”](#examples)
```plaintext
# Is there a newer release?
wtm upgrade --check
wtm upgrade
wtm upgrade --yes --output json
```
### Options
[Section titled “Options”](#options)
```plaintext
--check Report whether a newer release exists without installing anything
-h, --help help for upgrade
--output string Output format: text or json (default "text")
--version string Install a specific release instead of the latest (standalone installs only)
-y, --yes Skip the confirmation prompt
```
### Options inherited from parent commands
[Section titled “Options inherited from parent commands”](#options-inherited-from-parent-commands)
```plaintext
-q, --quiet Silence human output; errors and the exit code are unaffected, and --output json still emits its document
```
### SEE ALSO
[Section titled “SEE ALSO”](#see-also)
* [wtm](/0-28/reference/wtm/) - Orchestrate git worktrees and team dev workflows from the terminal