Isolated Dev Environments (Shadow Clones)

Working on spec-kitty often means running the CLI against a checkout while you are editing that same checkout's source. When you keep more than one checkout on a machine — a stable primary plus one or more experiment branches — two of them can quietly interfere with each other. This guide sets up a Shadow Clone: a standalone spec-kitty checkout whose CLI and runtime state are pinned to itself, so nothing it does leaks into the machine-global install or into sibling clones.

This is the non-containerised option. It is chosen deliberately for speed and low overhead; a container gives stronger isolation but costs startup time and disk on every invocation. If you need hard OS-level isolation, containerise instead — this guide is for the fast, everyday case.

The two things that actually leak

Isolation has exactly two axes. Miss either one and the clones are still entangled.

Axis What leaks if unisolated The lever
Code — which spec-kitty runs spec-kitty on your PATH resolves to the machine-global binary, so it runs global code against the clone's files. Edits to the clone's src/ have no effect until the global install is rebuilt. A clone-local .venv (editable install) placed first on PATH.
State — where the CLI reads/writes With SPEC_KITTY_HOME unset, every clone shares one runtime-state root at ~/.spec-kitty — auth tokens (auth/), config.toml, tracker credentials and stores, and the pre-review gate locks (gate-locks/). One clone's gate lock or tracker state then affects another clone's work. Point SPEC_KITTY_HOME at a clone-local directory.

SPEC_KITTY_HOME is the single environment variable that redirects the runtime state. When set, it is used verbatim as the state root (it is not suffixed with .spec-kitty), and it redirects both resolvers the codebase relies on — get_runtime_root() (runtime state) and get_kittify_home() (asset home). See src/kernel/paths.py and src/specify_cli/paths/windows_paths.py.

The parallel test suite isolates itself the same way — each pytest-xdist worker gets its own HOME/SPEC_KITTY_HOME so a run never touches the real ~/.spec-kitty. A Shadow Clone applies that discipline to interactive sessions.

Mental model: primary vs. shadow

  • Primary checkout — your stable clone. Its build backs the machine-global spec-kitty you use everywhere outside a Shadow Clone. When no Shadow Clone is active, the machine-global CLI is what runs.
  • Shadow Clone — any additional isolated checkout. Inside its session, a clone-local .venv and a clone-local state root take over; outside, nothing changed.

A Git worktree can use the same isolation: create its own .venv, then activate from inside that worktree. If its older branch lacks the helper, source the helper from a checkout that has it by absolute path — it still binds the current worktree.

Do not activate inside a spec-kitty-managed worktree under .worktrees/ (a lane, coord, or mission tree). The runtime creates and force-removes those itself during consolidate and context cleanup; because the clone-local .spec-kitty-home/ and .venv/ are git-ignored, teardown deletes them along with the worktree and leaves your shell pointing at a SPEC_KITTY_HOME and a spec-kitty CLI that no longer exist. Use a worktree you create yourself, outside .worktrees/.

Activation isolates your dev-env state root, not a Mission's write target. Sourcing the helper inside a worktree binds SPEC_KITTY_HOME — and with it your auth session, gate-locks, and tracker bindings under .spec-kitty-home/ — to that worktree. The product's own write-side root resolver does the opposite: locate_project_root / get_main_repo_root re-anchor a linked worktree back to the repository-root checkout (the PRIMARY-partition root a Mission's writes land in — not the Primary Branch main), so a worktree is not a distinct write target to it. The two resolvers therefore disagree about "where you are": drive one Mission partly from a repository-root-checkout shell and partly from a worktree-activated shell, and its gate-locks and ledger split across two state roots and stop coordinating. Shadow Clone activation is a dev-env convenience for running the CLI and tests against a checkout — it is not a way to own a Mission from a worktree. Mission-mutating commands fail closed when run from a checkout the Mission does not own; to give a Mission its own linked-worktree checkout — isolating runtime state and mission/ref writes together — use the product's explicit owned-checkout scope (--owned-checkout), not a Shadow Clone shell. Background: ADR scoped shadow workspace write boundary.

The rest of this guide keeps those two intact at the same time: the global CLI stays the default everywhere, and each Shadow Clone overrides it only for the shell session you activate.

One-time machine setup (the global CLI)

Do this once, from your primary checkout, so that spec-kitty works from any directory that is not an activated Shadow Clone. Two supported shapes:

A. Snapshot from source (recommended default). Install the primary checkout's build into an isolated app environment with pipx. This gives you a global spec-kitty that is your fork's code rather than the published PyPI release, without putting the primary checkout's .venv on your PATH:

# from the primary checkout root
pipx install --force .

Re-run that command whenever you want the global CLI to pick up new primary- checkout changes. It is a snapshot, not a live link: editing the primary checkout's source does not change the global CLI until you reinstall.

B. Live editable link. If you want the global CLI to always track the primary checkout's working tree, install it editable into a dedicated environment instead:

# from the primary checkout root
pipx install --force --editable .

Either way, confirm the global resolves as expected from a neutral directory:

cd ~   # anywhere that is not a Shadow Clone
command -v spec-kitty     # -> ~/.local/bin/spec-kitty (the pipx shim)
spec-kitty --version

Keep the primary checkout's own .venv for running its tests, but do not add it to your shell's default PATH. The global shim is the machine default; the primary .venv is just how you test the primary checkout.

Per-clone setup (each Shadow Clone)

For every additional checkout you want to isolate:

1. Create the clone-local virtualenv (editable install + dev extras)

Use the project's canonical install — the same command contributing.md prescribes. From the clone root it creates .venv, installs the clone editable (so the CLI always runs the clone's live src/), and pulls in the dev extras — the test and lint optional-dependency groups (pytest, pytest-xdist, ruff, mypy, …) plus the dev dependency group — resolved from the committed uv.lock:

# from the Shadow Clone root
uv sync --frozen --all-extras

Do not skip the dev extras: a Shadow Clone you cannot run the test suite, ruff, or mypy in is a half-isolated clone that still forces you back to another checkout to validate a change. uv sync installs them by default here, so a correctly set-up clone is self-sufficient.

No uv? Install it (https://docs.astral.sh/uv/), or fall back to the stdlib path — but install the extras explicitly so the clone stays self-sufficient:

python3.11 -m venv .venv          # match the project's supported Python (3.11+)
.venv/bin/python -m pip install -e '.[test,lint]'

Playwright's browser (for tests/ui/) is never committed; install it once per clone when you need the UI e2e tests: .venv/bin/playwright install chromium.

Verify the clone-local CLI and dev tools run the clone's code:

.venv/bin/spec-kitty --version
.venv/bin/pytest --version && .venv/bin/ruff --version && .venv/bin/mypy --version

.venv/ is already git-ignored, so this never becomes a committed artefact.

2. Activate the isolated environment for your session

A committed helper does both axes at once for the Git checkout you are in — prepends that checkout's .venv/bin to PATH and points SPEC_KITTY_HOME at a clone-local state root. Source it (do not execute it); it works from both bash and zsh:

# from the Shadow Clone root
source scripts/dev/activate-isolated-env.sh

It prints what it bound and how to undo it:

spec-kitty isolated env ACTIVE
  clone : <clone-root>
  venv  : <clone-root>/.venv
  state : <clone-root>/.spec-kitty-home
  cli   : <clone-root>/.venv/bin/spec-kitty
  undo  : deactivate_spec_kitty

The helper derives the root from the current Git checkout, falling back to its own location outside Git. The clone-local state root (.spec-kitty-home/) is git-ignored.

If an isolated environment for another checkout is already active (for example you activated in worktree A, then cd into worktree B and source the helper again), the helper first deactivates A, then binds B. A single deactivate_spec_kitty afterwards restores the shell as it was before A. Sourcing it again in the checkout that is already active changes nothing.

To restore the shell to the machine-global CLI and unset the overrides:

deactivate_spec_kitty

3. Verify the isolation

With the environment active:

command -v spec-kitty          # -> <clone-root>/.venv/bin/spec-kitty
echo "$SPEC_KITTY_HOME"        # -> <clone-root>/.spec-kitty-home
spec-kitty agent tasks status  # any command now writes only under .spec-kitty-home/
ls .spec-kitty-home            # clone-local runtime state (auth/, gate-locks/, ...) appears here

Confirm the machine-global root is untouched — nothing new should appear under ~/.spec-kitty as a result of commands run inside the Shadow Clone. The isolated state root starts without a login session, so a tracker or hosted call that needs authentication finds no credentials until you authenticate inside the activated Shadow Clone.

Optional: auto-activate with direnv

Sourcing by hand is reliable but easy to forget. If you use direnv, drop an .envrc at each clone root so the environment activates on cd and deactivates when you leave:

# <clone-root>/.envrc
source scripts/dev/activate-isolated-env.sh

Then direnv allow once per clone. direnv is optional and is not a project dependency; the sourced helper remains the baseline that needs nothing installed. If you commit an .envrc, keep it path-agnostic (as above) so it works in any clone.

What is (and is not) isolated

Isolated per clone once activated:

  • Which spec-kitty binary runs (clone .venv, editable → live src/).
  • Runtime state under SPEC_KITTY_HOME: auth, config, tracker credentials and stores, and gate-locks. (A queue.db or sync-daemon file under an old ~/.spec-kitty is inert residue from the retired sync transport.)

Deliberately not isolated:

  • The machine-global spec-kitty — it stays your default everywhere outside an activated Shadow Clone. Setting up a Shadow Clone never rewrites it.
  • Anything a command reaches over the network (a hosted Team Kitty or tracker backend is shared infrastructure regardless of local state). Isolation here is about local runtime state, not remote services.

Troubleshooting

Symptom Cause Fix
spec-kitty still resolves to ~/.local/bin/... after activating The helper was executed, not sourced, so the exports never reached your shell. source scripts/dev/activate-isolated-env.sh (note the leading source).
no .venv found at <root> The clone-local virtualenv was not created yet. Run step 1 (create .venv + pip install -e .).
no executable spec-kitty CLI found at <root>/.venv/bin/spec-kitty The checkout's .venv does not contain the Spec Kitty CLI. Install this checkout with step 1, then activate again.
A tracker or hosted call that needs authentication says Not authenticated This Shadow Clone has its own empty auth store. Authenticate again inside the activated Shadow Clone.
Edits to the clone's src/ have no effect The active CLI is the machine-global one, or the .venv install is not editable. Activate the env; reinstall with pip install -e ..
A gate lock, tracker binding, or login seems to come from another clone SPEC_KITTY_HOME was unset, so the shared ~/.spec-kitty was in play. Activate the env; confirm echo "$SPEC_KITTY_HOME" points inside the clone.
State appeared under ~/.spec-kitty while working in a clone A command ran before activation. Deactivate/reactivate; run clone commands only inside an activated session.