---
name: stage
description: Run a local test stage, or a personal cloud dev stage with --live.
---

**Host-native invocation:** Claude `/mmi:stage` · Codex `$mmi:stage` · Cursor `/stage` · jervcode `/stage` · Hermes skill_view. A backticked `/name` in this doc names the matching workflow (this skill or a sibling), not a literal command.

# /stage — local test environment

A throwaway **local** environment to exercise the current branch — a dev server / local stack, plus
Playwright or other tools where the project uses them. It is **off the promotion train**: any developer,
no version effect, no deploy. It lives in a **gitignored** workspace, **force-kills the previous stage**
before starting, and **auto-decommissions** when done.

Use `/stage` as the normal path for local previews, smoke tests, and Playwright targets. If the user
already asked to run or preview the app, that is enough authorization to start it; when intent is unclear,
ask before creating or destroying a stage. Manual dev-server commands are allowed when the user explicitly
wants a bypass or when a bounded diagnostic needs one, but say why. If the stage path itself fails, diagnose
from `mmi-cli stage --json`, `tmp/stage/state.json`, process/port/container evidence, and file Hub/org-default
friction instead of silently working around it.

The stage recipe is the **registry-derived default** for plumbing-free product repos: a `tenant-container`
repo that ships `docker-compose.yml` and has a Hub registry `portRange` needs **no committed config**.
`mmi-cli stage` derives a default compose stage — run `docker compose build --no-cache`, run `docker compose
up -d`, choose a free port from the registry range, and report the local URL (e.g. `http://127.0.0.1:3700/`).
The commands are shell-aware and `--apply` runs them cross-shell. Board, deploy, secret, and project registry
facts all live in the Hub registry — no repo-local control-plane file.

**Secrets are vault-native — no `.env` on disk (#2655).** The org forbids *using* `.env` files, not just
committing them. A repo **without** `.env.example` stages vault-native: `mmi-cli stage --apply` resolves the
declared dev runtime secrets (registry `requiredRuntimeSecrets[dev]`) from the vault and injects them straight
into the compose **process environment**, which Compose interpolates the file with — no secret ever touches
disk. A repo that still ships `.env.example` keeps the legacy `.env` bootstrap for back-compat until its
compose migrates to passthrough; new repos should ship **no** `.env.example`.

**Runtime env reaches the container through the generated passthrough (#6345).** Interpolation is not
delivery: a container receives only what its own `environment:` declares. So `/stage` mirrors the box deploy —
it generates `tmp/stage/runtime-env.override.yml` (gitignored, **key names only**, values stay in the process
env) passing every runtime key through to each service the compose marks `labels: {"mmi.runtime-env": "true"}`,
and loads it beside `docker-compose.yml` for the whole stage. Mark the app service with that label; a repo
that hand-declares its variables needs nothing. Unmarked sidecars (db, cache) keep getting only what they
interpolate themselves. Never wrap an individual `docker compose` call in `mmi-cli vault secrets use` to work
around a missing value (#6344) — that value is scoped to the one child process it wraps, so the next `docker
compose logs`/`down` fails the same `required variable … is missing a value`. Declare the key in registry
`requiredRuntimeSecrets[dev]` and let `/stage` carry it across the whole lifecycle.

**Build-time secrets (#5381).** When META declares `requiredBuildSecrets` (e.g. Katip's
`NODE_AUTH_TOKEN=@github-packages-token` for `@mutmutco/fofu-ds` on GitHub Packages), `/stage` injects those
BuildKit ids into the **`docker compose build`** process env only — not into the running container. Central
deploy mints the packages sentinel from Actions `GITHUB_TOKEN`; a laptop cannot. Resolution order: existing
process env → stageless project vault secret under the BuildKit id → existing GitHub sign-in
(`GH_TOKEN`, `GITHUB_TOKEN`, or `gh auth token`). Package read access is still required and is enforced
by GitHub Packages during the build. If every source is missing, `/stage` fails before the build.
Setup: [`docs/Guides/tenant-runtime.md`](../../docs/Guides/tenant-runtime.md) § Local `/stage`. Never commit
or print the PAT.

## Step 0 — inspect the plan

```bash
mmi-cli stage --json
```

The JSON reports `source` (`derived` / `local` / `none`) and, when derived, the local `url`. `source: none`
means neither a usable local recipe nor a derivable default exists — the message names the missing fact
(deployModel, `docker-compose.yml`, or registry `portRange`; `.env.example` is **not** required — #2655).
On a `deployModel: vercel` repo the receipt also carries `preview` — the current branch's latest
Vercel **Preview** deployment (`state`, `url`) read from the GitHub Deployments the Vercel GitHub app
posts — and the text output ends with a `vercel preview (<branch>)` line. Report that URL as the branch
preview alongside the local stage; no `preview.deployment` means the branch was not pushed yet or the
Vercel GitHub app is not connected. There is no box stage on a vercel repo: `--live` refuses.
When the gap is a missing Hub registry `portRange`, the JSON receipt also carries `recovery` with
`mmi-cli stage port-range <owner/repo>` (#5539) — assign the block, then re-run `/stage`. That gap does not
mean the repo's Hub registry/org project setup is missing; it means local stage derivation cannot pick a
collision-safe port until META.portRange exists.

## Step 1 — run the stage

A stage is **per worktree**. The CLI force-stops only the previous stage **in this worktree**, runs
`stage.build`, starts `stage.up`, records `tmp/stage/state.json`, picks a free port from the registry
range (skipping ports reserved by sibling worktrees), and polls `stage.healthUrl` when configured.
Recipes run in Bash, including Git Bash on Windows. Use Bash syntax for build, up and teardown;
legacy cmd `set VAR=value` and PowerShell `$env:VAR` recipes must be converted to Bash or Node scripts.
Windows requires Git Bash or an absolute `SHELL` path to its `bash.exe`; there is no cmd fallback.
Use `--port` to pin a port when needed:

```bash
mmi-cli stage run --apply
mmi-cli stage run --apply --port 5180
```

Use a larger bound when the repo's local stack is known to be slow:
```bash
mmi-cli stage run --apply --timeout-ms 120000
```

All stage artifacts (build output, screenshots, Playwright traces, local DB files) stay under `tmp/stage/`
— never tracked.

A local stage is bound to the worktree that started it. Concurrent worktrees on one machine each keep
their own stage on separate ports — you do not need to stop one before starting another. For `jervaise`, continuity
keys on branch; use a distinct North Star slug per parallel grind or feature. Stage JSON/state records
the starting `cwd` plus git branch/commit when available.

### Step 1a — post-smoke panel (when criteria exist)

When grinding or the user supplied **acceptance criteria** (from the issue body at Gate 1 — not
ad-hoc chat text), run a **Budget-routing panel** on observable stage signals before teardown:

1. **Panel** (parallel):
   - **requirements-match** (budget tier) — does the staged URL/behavior meet the criteria?
   - **runtime-health** (budget tier) — console errors, failed `healthUrl`, broken UI signals
     *(smoke observability — not the grind `correctness` hard lens)*
   - **tests-actually-test** (budget tier) — if Playwright ran, did it exercise the changed path?
2. **Confirm** — get an independent second model to verify the staged URL or behavior meets the acceptance criteria.
3. Feed the result back to the active loop or report to the human. A blocker
   means the stage failed smoke — do not claim the grind criterion met.
   This panel is **not** a security clearance — grind Phase 2 still runs the `security` hard lens.

Skip when `/stage` is ad-hoc preview with no criteria.

## Step 2 — stop when done

```bash
mmi-cli stage stop --apply
```

Stop the stage when the work is done, before switching context, or before replacing it with another stage.
If the user clearly wants the preview to stay up, leave it running and report that. The next `/stage` also
stops the previous recorded stage before starting, so a stale server does not linger between runs. For the
registry-derived Docker Compose default, stop also runs the recorded compose teardown (`docker compose down`)
from the original stage working directory. Stop re-fetches the declared dev runtime secrets so that `down`
can interpolate the same file (`${POSTGRES_PASSWORD:?}`, `${PORT:?}`) — secrets never come from the state
file (#6351). A vault-native repo with no `.env` gets `PORT` defaulted to the bound stage port.

## /stage --live — personal cloud dev stage

Project admins can deploy the **current branch** to their project's dev runtime without an IP
restriction. The CLI dispatches legacy gate cleanup, then the dev deploy through the Hub backend.
Cleanup removes only that project's old stage rules and creates no Cloudflare rules, so rule-slot
quotas do not block staging. It still reads Cloudflare to detect old gates; cleanup failures stay visible.
The stage is accessible from any IP after both workflows succeed. The app's own sign-in still applies.
No SSH from your machine; project authorization remains enforced by the Hub.

**Deploy-model preflight (#5466 / #5467).** `/stage --live` is **not** generally available. Read
`deployModel` first (`mmi-cli oracle org project get {owner}/{repo}` → `deployModel`, or the dry
`mmi-cli stage --live --json` refusal). It applies only to **`tenant-container`** repos (the same
model the Hub tenant-deploy + tenant-control gates require). **`registry-publish` is not applicable**
for live stages — nor are hub-serverless, solo-container, static-cdn, vercel (its preview URL is on the
plain `/stage` receipt), content, or none. For an
unsupported model the dry plan refuses **before** offering `--apply` / `--down`; do not invent a
tenant deploy.

```bash
mmi-cli stage --live           # dry-run plan (refuses when deployModel is not tenant-container)
mmi-cli stage --live --apply   # clear legacy gates + deploy without an IP restriction
```

Tear it down when done — **only when `--apply` actually created a runtime**. This stops the runtime
without Cloudflare work:

```bash
mmi-cli stage --live --down --apply
```

If apply never landed (unsupported model, failed dispatch, or you never ran `--apply`), `--down` is a
**safe no-op** — do not promise teardown of a stage that was never created. Personal dev stages only —
rc/live environments still move exclusively through the promotion train.

## Step 3 — report

The stage URL, what's running (server + tools), the workspace path (`tmp/stage/`), and the teardown command.

## Notes

- `/stage` is local — no AWS, no deploy, no board or version effect. The one cloud exception is
  `--live`: an on-demand **dev** stage of your branch for **`tenant-container`** repos only, without
  a stage IP restriction (above). Preflight `deployModel` before offering it; `registry-publish`
  and other models have no live stage.
- Remote rc/live environments move only through `/rcand`, `/release`, and `/hotfix`.
- Everything is gitignored; `/stage` never produces a tracked change.
- **Playwright MCP output goes to `tmp/`**, never the repo root: if you drive the Playwright MCP server,
  pass `--output-dir tmp/playwright-mcp` (or point its output there). `.playwright-mcp/` is kept gitignored
  by the org `.gitignore` managed block (`mmi-cli doctor`) as a safety net, so a stray default capture is
  never tracked.
- `tools[]` is declarative for now; stage hardening starts/stops the main configured process. Repos with
  extra local services should encode them behind `stage.up` until a tool runner is added.
- **Stale Docker bundle:** the registry-derived Docker Compose default builds with `docker compose build
  --no-cache` before `up`. If the browser still serves an old bundle, run `mmi-cli stage stop --apply`, rerun
  `/stage`, then inspect `tmp/stage/state.json` identity, container image labels, and compose build inputs.
- **Stale `.env`:** when `.env` already exists from a prior `/stage` run, `stage run` does **not** refresh it
  from an updated `.env.example` — it warns on stderr and keeps the stale file. Delete `.env` (or merge in the
  new keys by hand) when `.env.example` changes, then re-run `/stage`.

## Retro — one check before you finish
Before your final report, answer one question honestly: did **this skill's own instructions** misfire
this run — ambiguous wording, a misleading message, or an environment failure it should have warned
about? (Process only — never the user's code or task; e.g. a teardown that left a port bound, or a
Playwright output path aimed at the repo root.) If yes, file **one** lesson and move on; a clean run is
silent (hard cap: one per run). It lands on the owning board (deduped) and is fixed only via a reviewed PR —
never edit the skill live; the retro is advisory, so if the call fails, note it and continue. File through MMI:
`mmi-cli learning skill-lesson --skill stage --title "<what misfired>" --body "<what; evidence; proposed amendment>"`
