# Agent card — Rivalatch Ship MVP v0

Public name **Rivalatch**. Wire identifiers stay VibeGate (`VIBEGATE_*`, `vibegate-mcp`, `X-VibeGate-Signature-256`). Spec untouched.

Call the gate. Do not invent fields. Spec: [`../../vibegate-spec/MVP_V0.md`](../../vibegate-spec/MVP_V0.md) (repo-relative from this package).

## Install (stranger, no chat history)

**Preferred: versioned wheels** (no editable checkout, no chat). From a checkout of `https://github.com/ConWan30/vibegate` on `main`:

```bash
python3 -m venv .venv && . .venv/bin/activate
pip install -U pip build
(cd vibegate-runner && python -m build -w)
(cd vibegate-ship && python -m build -w)
pip install \
  vibegate-runner/dist/vibegate_runner-0.1.0-py3-none-any.whl \
  vibegate-ship/dist/vibegate_ship-0.1.0-py3-none-any.whl
# Or from a wheels drop: pip install ./wheels/vibegate_runner-0.1.0-py3-none-any.whl \
#   ./wheels/vibegate_ship-0.1.0-py3-none-any.whl
export VIBEGATE_API_KEY=dev-key          # invent your own; required
# export VIBEGATE_SANDBOX=mock           # optional; default mock when unset
python -m vibegate_ship                  # http://127.0.0.1:8080
```

One env card: `VIBEGATE_API_KEY` (required). Optional: `VIBEGATE_SANDBOX` (`mock` default). MCP card: [`MCP.md`](./MCP.md).

Editable peer (dev only): `pip install -e ../vibegate-runner` then `pip install -e '.[dev]'` from `vibegate-ship/`.

## Tools / HTTP

| Name | HTTP |
|------|------|
| `gate.run` | `POST /v1/gate/run` |
| `gate.status` | `GET /v1/gate/status/{job_id}` |
| `gate.get_result` | `GET /v1/gate/results/{job_id}` |

Auth: `X-Api-Key: $VIBEGATE_API_KEY`.


## MCP

Shared door over MCP — not a sandbox. Same three tools as HTTP.

- **stdio:** `vibegate-mcp` → `vibegate_ship.mcp_stdio:main` · `python -m vibegate_ship.mcp_stdio`
- **HTTPS (optional):** `POST /mcp` — JSON-RPC body isomorphic to stdio (`streamable-http` Spec peer). Auth: `X-Api-Key` same as REST.
- Install cards (Cursor / Claude Code / Codex / Grok Build): [`MCP.md`](./MCP.md) · served at `/MCP.md`

stdio remains for local agents; remote MCP is additive. Clients that prefer REST use the HTTP table above.

## Directory ops (not GateResult)

Domain challenge for OpenAI Apps Directory (plain text, ops only):

```bash
export VIBEGATE_OPENAI_APPS_CHALLENGE='<token-from-openai>'
# GET /.well-known/openai-apps-challenge → that token (404 if unset)
```

Listing URLs (website / support / privacy / terms) stay off-wire ops. No GateResult / OpenAPI property invention.


## Minimal `gate.run` body

```json
{
  "app_ref": { "type": "bundle", "uri": "fixtures", "rev": null },
  "checks": ["smoke"],
  "acceptance": { "criteria": [], "base_url_path": "/" },
  "idempotency_key": "<uuid>"
}
```

Optional `webhook`: `{ "url": "https://…", "secret_id": "…" }`.

`app_ref.uri: "fixtures"` resolves from packaged Runner fixtures — not a host `/workspace` path.

### Real bundle (`apps/hello`)

Mock sandbox also accepts a **real** static peer bundle (not the fixtures token):

| `app_ref.uri` | Resolves to |
|---------------|-------------|
| `apps/hello` | Monorepo `<repo>/apps/hello` (cwd-independent) |
| absolute path | That directory (e.g. `C:\Users\Contr\vibegate\apps\hello` on Windows) |
| `file:///C:/Users/Contr/vibegate/apps/hello` | Same via `file://` |

```json
{
  "app_ref": { "type": "bundle", "uri": "apps/hello", "rev": null },
  "checks": ["smoke"],
  "acceptance": { "criteria": [], "base_url_path": "/" },
  "idempotency_key": "<uuid>"
}
```

Smoke: `python scripts/hello_bundle_smoke.py` · `pytest -q tests/test_hello_bundle.py`. See [`../../apps/hello/README.md`](../../apps/hello/README.md).


## Webhook HMAC (stranger)

Optional on `gate.run`: `"webhook": { "url": "https://…", "secret_id": "…" }`.

- Header: `X-VibeGate-Signature-256: sha256=<hex>` over the raw POST body (canonical JSON: sorted keys, compact).
- Body: GateResult fields + `idempotency_key` only — never `compose_source` / `hold_readings` / compose meta.
- Secret: `VIBEGATE_WEBHOOK_SECRET` or `VIBEGATE_WEBHOOK_SECRET_<SECRET_ID>` (never print).
- **Fail-closed:** if `secret_id` is set but the secret env is missing, Ship does **not** send an unsigned webhook.
- Verify with the same secret; reject missing header, wrong format, wrong secret, tampered body.

Proof (no secrets in output): `PYTHONPATH=src:../vibegate-runner/src python scripts/stranger_webhook.py`


## Knock once (run → status → result)

```bash
IDEM=$(uuidgen | tr '[:upper:]' '[:lower:]')
JOB=$(curl -sS -X POST http://127.0.0.1:8080/v1/gate/run \
  -H "Content-Type: application/json" \
  -H "X-Api-Key: $VIBEGATE_API_KEY" \
  -d "{\"app_ref\":{\"type\":\"bundle\",\"uri\":\"fixtures\",\"rev\":null},\"checks\":[\"smoke\"],\"acceptance\":{\"criteria\":[],\"base_url_path\":\"/\"},\"idempotency_key\":\"$IDEM\"}")
echo "$JOB"   # 202 new key; 200 same key+payload; 409 conflict
JOB_ID=$(echo "$JOB" | python3 -c 'import sys,json; print(json.load(sys.stdin)["job_id"])')
curl -sS -H "X-Api-Key: $VIBEGATE_API_KEY" "http://127.0.0.1:8080/v1/gate/status/$JOB_ID"
curl -sS -H "X-Api-Key: $VIBEGATE_API_KEY" "http://127.0.0.1:8080/v1/gate/results/$JOB_ID"
```

Or: `PYTHONPATH=src:../vibegate-runner/src python scripts/stranger_caller.py`


## ChatGPT plugin-style review pack

Spec-frozen **5+3** review cases + local demo of **Lodged(202) → status → Recalled(200) → Occupied/Contested(409)** (no remote MCP, no Spec fields). Prose tags only — wire unchanged.

- [`CHATGPT_PLUGIN_REVIEW_PACK.md`](./CHATGPT_PLUGIN_REVIEW_PACK.md) (vocabulary table)
- `pytest -q tests/test_chatgpt_plugin_review_pack.py`
- `PYTHONPATH=src:../vibegate-runner/src python scripts/chatgpt_plugin_review_demo.py`

## Mobile knocker (thin proof)

Share / deep-link / MCP lodge into the **same** three tools — no store app, no app detect, no marketplace, **Spec diff: none**.

- Doc: [`MOBILE_KNOCKER.md`](./MOBILE_KNOCKER.md)
- Codec: `vibegate_ship.mobile_knocker` (`rivalatch://knock?…` → frozen `gate.run` body)
- `pytest -q tests/test_mobile_knocker.py`
- `PYTHONPATH=src:../vibegate-runner/src python scripts/mobile_knocker_proof.py`

## Read results

- Status: `{ job_id, state, updated_at }` — `queued|running|succeeded|failed`
- Result (terminal): GateResult — `verdict`, `noul_acceptance_met`, `thresholds.pass_min`, `checks`, `repro`, `fail_class`
- Non-terminal `get_result` → **202** + GateStatus
- Never expect `compose_source` or `hold_readings` on the public result

## Repro hygiene (frozen shapes)

Public `repro` on GateResult (no Spec bump):

- `screenshot_uris`: string **array** of absolute paths under `VIBEGATE_ARTIFACT_ROOT/<job_id>/` (job-scoped)
- `dom_snapshot_uri`: scalar `string|null` (overwrite on repeat; same job root)
- Not `file://` URIs. Not a CDN. Shape stays frozen — do not invent signed-URL fields.

Smoke-only knocks leave `screenshot_uris: []` and `dom_snapshot_uri: null`.

Authenticated byte fetch (Spec ACCEPTED 2026-10-04; not a fourth MCP tool):

```bash
curl -sS -H "X-Api-Key: $VIBEGATE_API_KEY" \
  "http://127.0.0.1:8080/v1/gate/jobs/$JOB_ID/artifacts/screenshot.png" -o screenshot.png
curl -sS -H "X-Api-Key: $VIBEGATE_API_KEY" \
  "http://127.0.0.1:8080/v1/gate/jobs/$JOB_ID/artifacts/dom.html" -o dom.html
```

Only names listed on that job's `repro` are served. Unknown / unlisted / missing → 404.

## Two-process / JobStore repro replay

Mock fixtures only (`uri: fixtures`, checks smoke+screenshot+dom). Start Ship → 202/200 → kill → new process same `VIBEGATE_JOBSTORE_DIR` → rebuild body from disk request → 200 same `job_id`; repro paths still exist. No Spec fields.

```bash
PYTHONPATH=src:../vibegate-runner/src python scripts/two_process_replay.py
# optional soak (skip-closed without key):
python ../vibegate-runner/scripts/e2b_soak.py
```


## Compose reminder

- Empty criteria → 0.0/1.0 code-compose only
- Non-empty → TypeSafe noul (fail-closed 0.0); never invent mid-floats on empty path

## Schemas on disk

- Repo: `openapi/vibegate-v1.yaml`, `mcp/tools.json`
- Installed: `import vibegate_ship.resources as r; r.openapi_path()`, `r.mcp_tools_path()`

## Door board (read-only)

Operator UI at `/door/` — see [`DOOR_BOARD.md`](./DOOR_BOARD.md). Rival knock + session feed; polls status/result; conflict panel uses public 409 held|incoming.

### Fail fixture + Jev knock

- `"uri": "apps/broken"` — intentional missing `index.html`; smoke → GateResult fail (`fail_class: assert`)
- Non-empty `acceptance.criteria` → TypeSafe Jev noul (fail-closed `0.0`/`fail` if key/CLI missing; uses `TYPESAFE_API_KEY` when set). Never print secrets.
- Windows broken path: `C:\Users\Contr\vibegate\apps\broken` or `file:///C:/Users/Contr/vibegate/apps/broken`
