---
name: pitchstation-devbox
description: Open, size, sync and govern a PitchStation DEV INSTANCE — a cloud workbench sized for one repository with Claude Code / Kimi CLI / Codex pre-installed. Use when the user says "open a dev box for this repo", "start/stop my dev box", "sync my changes to the dev box", "the box ran out of memory", "which dev boxes do I have and what do they cost", or "terminate the dev box". Sizes from the checkout, shows the all-in monthly price, reserves in PitchStation BEFORE any cloud call, provisions from the operator machine under a scoped AWS profile, and moves code with git (never rsync --delete, never force). Requires the devbox.manage capability.
---

# pitchstation-devbox

A dev instance is **one person's cloud workbench for one repository**. PitchStation is the
registry and policy engine only: it never makes a cloud call and holds no login credential
for any box. Everything that touches AWS or the box runs **here, on the operator machine**,
through `devbox.py`, and is confirmed back to the registry.

```
~/.claude/skills/pitchstation-devbox/devbox.py                    # this CLI (stdlib Python)
~/.claude/skills/pitchstation-devbox/scan.py                      # sizing profile from the checkout
~/.claude/skills/pitchstation-devbox/provision-aws-instance.sh    # AWS phases (called by devbox.py)
```

Install all four files (the provisioner ships beside the CLI; `DEVBOX_PROVISIONER` overrides its path):

```bash
mkdir -p ~/.claude/skills/pitchstation-devbox && cd $_
for f in SKILL.md devbox.py scan.py provision-aws-instance.sh; do curl -fsSL -o "$f" "https://www.pitchstation.ai/downloads/pitchstation-devbox-$f"; done
chmod +x devbox.py scan.py provision-aws-instance.sh
```

The control-plane URL is taken only from `PITCHSTATION_URL` in your environment or the share-to-pitchstation skill's `.env`, must be https, and must be a pitchstation.ai host (or loopback). A repository's `.env` may hold a token for itself but can never redirect where tokens are sent.

Auth resolves from `PITCHSTATION_TOKEN` in the environment, else
`~/.claude/skills/share-to-pitchstation/.env` — never from a repository's `.env` (an untrusted checkout chooses neither the identity nor where it is sent). AWS uses profile `pitchstation-devbox`
(`generator/scripts/iam/README.md`); root credentials are refused.

## The five contract rules (never break these)

1. **Size before reserve, reserve before cloud.** Run `size` and show the class, the reasons and
   the **all-in monthly price** (compute + storage + public IPv4, running and stopped). Get **one
   confirmation**, then `provision` — which reserves in PitchStation first and only then launches.
2. **Never store or type a vendor login.** After provisioning, tell the user the exact login to run
   in their first SSH session (`claude auth login`, `kimi` → `/login`, `codex login`). API keys, if
   they prefer them, explicitly choose local `--env-file` or owner-only `--server-env`. Stored env
   remains encrypted; only the owner can reveal it. Values travel over SSH stdin, never user-data,
   argv, source sync or log values. Do not reveal stored keys without the user's explicit request.
3. **Never sync secrets.** `.env*`, databases, keys are git-ignored on both sides and excluded from
   assets. If `scan` reports `secretsSeen`, say so.
4. **Never overwrite.** `push` refuses when the box has commits you have not pulled or its worktree
   is dirty; `pull` refuses non-fast-forward. When both refuse, say the two sides diverged and offer
   `pull NAME --branch <branch>` (fetches into a side branch for review). Do not "fix" it with force.
5. **Terminate needs the instance id and a fresh check.** `terminate NAME --instance i-…` runs
   `git status` on the box first and refuses if anything is unreconciled; `--discard` is the user's
   explicit choice, never yours.

## What the user says → what you run

| User says | You run | Then say |
|---|---|---|
| "open a dev box for this repo" / "for ~/prj/x" | `devbox.py size PATH` → show class + price + reasons → confirm → `devbox.py provision PATH --name NAME [--agents claude,kimi] [--size L] [--concurrency 4]` | `ssh NAME`, the per-agent login line, the cost, and that it stops itself after 60 idle minutes |
| "… with kimi" / "… with codex" | add `--agents kimi` / `--agents codex` (Node is installed for codex, uv+Python for kimi) | same |
| "start NAME" / "stop NAME" | `devbox.py start NAME` / `devbox.py stop NAME` | start waits for SSH/bootstrap readiness; EIP retained unless explicitly released (then a new address/alias) |
| "sync my changes" / "push" | `devbox.py push NAME [--repo PATH]` | what moved; if refused, why and the way out |
| "pull the agent's work back" | `devbox.py pull NAME` | fast-forwarded, or diverged → side branch |
| "what's the state of the box's repo" | `devbox.py status NAME` | fresh cloud confirmation; when running, fresh operator/box/origin graphs and box dirtiness |
| "the box ran out of memory" / "typecheck OOMs" | `devbox.py list` (see last report) → `devbox.py resize NAME <next class>` (`--confirm-xl` for XL, after the user says yes) | new class, new price; resize is stop → modify → start |
| "disk is full" | `devbox.py grow NAME <GB>` | grows the volume AND the filesystem; verified with `df` |
| "which dev boxes do I have / what do they cost" | `devbox.py list` | per box: state, class, $/month, cost to date; committed total |
| "rename the box" | `devbox.py rename OLD NEW` | tag, registry and ssh alias updated |
| "I can't ssh in" (IP changed) | `devbox.py allow-ip NAME` | the security group now allows the current address |
| "terminate / delete the box" | `devbox.py terminate NAME --instance i-…` | what the fresh check found; EIP released; row kept for audit |
| "something is stuck / an instance is orphaned" | `devbox.py reconcile` | tagged instances/EIPs/volumes/SGs/key pairs; ownership, dependencies, conflicts and repair/cleanup choices |

Name rule: lowercase letters, digits, hyphens (2–40). Suggest `<repo>-dev`.

## Sizing, in one paragraph you can repeat

The scanner finds workspaces (TypeScript or not), JVM, compose, Flutter, Rust, Python, and the
size of the tree with and without dependency trees. The engine adds the **two largest** RAM
allowances (one build or typecheck plus one agent-driven run; `--concurrency 4` if the user runs
all typechecks in parallel) to a 1 GB base and picks the smallest class whose RAM covers that
with 20 % headroom. Swap is never counted. Disk is 6 GB + 2× repo + 1.2× deps + caches, never
below 20 GB. After the first real install + typecheck on the box, the measured peak replaces the
estimate. Classes: S 2 GB · M 4 GB · L 8 GB · XL 16 GB. `--size` overrides; both the proposal and
the choice are stored. `--type` must map to the catalog class/architecture; `--disk` must cover
the chosen class minimum and stay ≤ 2000 GB. Never choose XS for a repo.

## Warm-up, reassignment and explicit recovery

- `provision PATH --name NAME --warm` or `warm NAME --repo PATH` measures install/typecheck
  using GNU time and persists HEAD-bound peak RSS/disk after success. pnpm/Yarn recipes require
  packageManager pins in package.json; unknown toolchains need installed tools and explicit
  `--warm-command`. Failed commands or HEAD changes save no new measurement. `size PATH --name NAME`
  prefers matching stored measurements; sampled/sequential peaks do not prove every concurrent load.
- Default sync is operator transport, with no origin credential on the box. Explicit
  `--sync-mode origin` validates a clean operator checkout equal to freshly fetched origin before
  reservation, then clones with separately configured box credentials. Never copy operator origin
  credentials implicitly. Origin-mode termination freshly fetches origin and refuses unpushed work.
- `repo NAME PATH` freshly reconciles old work, scans the new repo and asks the API to recompute
  class AND disk before assignment/sync. A 409 returns the full sizing proposal; resize first,
  then retry. New work defaults to a separate directory; old work is never discarded.
- If no AGENTS.md/CLAUDE.md exists, provisioning offers instructions. Explicit `--instructions`
  or `instructions NAME --repo PATH --write [--deploy-command COMMAND]` creates one exclusively;
  review/commit before the next push. Existing files are preserved; never guess a deploy command.
- Policy auto executes owned, non-stale, next-class proposals on relevant CLI invocations only;
  dry runs and explicit stop/terminate/rename/resize do not trigger unrelated mutations. XL always
  requires explicit confirmation. AWS execution stays in the operator provisioner, never the server.
- `reconcile --repair NAME` resumes exact pending resize/provisioning intent or repairs confirmation,
  Name tag and alias. Discovery errors, foreign scopes and missing already-bound identities fail
  closed without replacement launches. Never bypass a conflict with root credentials.
  After EIP release, start/resize/disk repair recover the exact owned/tagged address before SSH;
  ambiguous addresses and conflicting attachments are refused. `/resized` confirms the optional
  `publicIp`/`eipAllocId` pair atomically with the approved intent (both or neither). Failed recovery
  or confirmation may leave a running, billable instance: inspect and retry the same repair, never
  bypass it with `/started`. Update the SSH alias only after successful confirmation.
- Orphan cleanup needs `reconcile --cleanup ID --confirm ID`. Only a verified owned, unattached,
  unregistered resource qualifies; shared/foreign/unknown resources are protected. Instance/volume
  deletion also needs the user's explicit `--discard`; data is not recoverable here. Apply the
  updated scoped IAM policy before using cleanup. Never infer discard from a request to inspect.
- List offers EIP release after 30 stopped days. `release-eip NAME --confirm eipalloc-ID` requires
  observed stopped state and exact identity. Explain that the stable address is lost and the next
  start obtains a new one; never release automatically. Commitment still counts future IPv4 costs.

## Post-provision message template

```
Dev instance NAME is ready (class L, t4g.large, $57.5/month running, $8.5 stopped).

  ssh NAME
  claude auth login        ← run this once in that session (Claude Code 2.1.258 is installed)

Your repo is at ~/work/NAME on the box (git; the box is remote "devbox" of your checkout).
  devbox.py push NAME      send your commits          devbox.py pull NAME   bring the agent's commits back
  devbox.py status NAME    dirty / ahead / behind     devbox.py stop NAME   stop now (it also stops itself after 60 idle min)
```

## Failure modes

| Symptom | Meaning | Do |
|---|---|---|
| `402 COST_CAP` at reserve or resize | the user's monthly cap (admin-set, default $60) would be exceeded; the numbers are in the message | tell the user the committed / requested / cap figures; an admin can raise the cap or record a one-off override |
| `409 NAME_TAKEN` | a live box with that name exists | pick another name or `list` |
| provisioner dies mid-run (VPN drop) | the reservation still exists; resources are tagged | re-run `provision-aws-instance.sh --kind devbox --reservation ID` — it converges; the token is reissued |
| `409 STATE_CONFLICT` on a lifecycle call | the registry moved on (a report or another confirmation landed first) | `devbox.py list`, then act on the current state |
| box shows `failed` + needsReconcile | provisioning lease expired (30 min) | `devbox.py reconcile`, then explicit `reconcile --repair NAME`; inspect identity conflicts before further action |
| box shows `stopped (unconfirmed)` | it announced it would stop and then went silent (final report lost) | `status NAME` / `reconcile` observes AWS and confirms; pending resize must be repaired before separate start/stop |
| `push` refused: box ahead | the agent committed on the box | `pull` first |
| `push` refused: dirty worktree on the box | uncommitted edits on the box | ssh in and commit/stash, or `pull` after committing there |
| `pull` refused: non-fast-forward | both sides moved | `pull NAME --branch <branch>` into a side branch, merge by hand |
| ssh times out | the operator's IP changed (SG is operator-/32 by default) | `devbox.py allow-ip NAME` |
| refuses to run: root credentials | ARCH-5 | create the scoped IAM user (`generator/scripts/iam/README.md`) |
| `NOT_IMPLEMENTED` from `/size` | server predates M2 | pass `--size CLASS` explicitly |

## What this skill never does

- Never calls AWS from anything but `provision-aws-instance.sh` under the scoped profile.
- Never puts a token, key or password into user-data, a command-line argument, or a commit.
- Never `git push --force`, never `rsync --delete`, never `reset --hard` toward the box.
- Never terminates without the instance id and a fresh check, and never passes `--discard` on its own.
