.ts` that parses the payload and dispatches via `dispatchByLabel` (label path) or `dispatchByIntent` (comment path). Webhook handlers must return within 10 s — fire `processRequest` with fire-and-forget semantics.
+3. **Register the event handler** in `src/app.ts` alongside the existing `app.webhooks.on(...)` calls.
+
+Webhook handlers do **not** run business logic — they parse the event, build a `BotContext`, and dispatch. All bot work happens in workflow handlers, called from the daemon.
diff --git a/docs/CHANGELOG.md b/docs/changelog.md
similarity index 100%
rename from docs/CHANGELOG.md
rename to docs/changelog.md
diff --git a/docs/index.md b/docs/index.md
index 61159225..a900c58c 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -5,20 +5,36 @@ hide:
# GitHub App Playground
-A GitHub App that responds to `@chrisleekr-bot` mentions on pull requests and issues, powered by the Claude Agent SDK. Every request is handed off to the daemon fleet over WebSocket; when triage flags the job as heavy or the queue backs up, the orchestrator spawns an ephemeral daemon Pod on Kubernetes so the same image scales on demand.
+A GitHub App that responds to `@chrisleekr-bot` mentions on pull requests and issues, powered by the Claude Agent SDK. Every webhook is acknowledged in under ten seconds and handed to the daemon fleet over WebSocket; when triage flags the job as heavy or the queue backs up, the orchestrator spawns an ephemeral daemon Pod on Kubernetes so the same image scales on demand.
-## Start here
+## Three doors
-- **[Setup](SETUP.md)** — GitHub App creation, local tunnel, environment variables.
-- **[Architecture](ARCHITECTURE.md)** — end-to-end request flow, from webhook through the daemon fleet to the tracking comment.
-- **[Deployment](DEPLOYMENT.md)** — Docker build, health probes, resource sizing.
-- **[Extending](EXTENDING.md)** — add new webhook handlers and MCP servers.
+
-## Operator guides
+- :material-account-voice:{ .lg .middle } __Use the bot__
-- [Configuration](CONFIGURATION.md) — every environment variable the app reads.
-- [Observability](OBSERVABILITY.md) — log fields, dispatch reasons, alerts.
-- [Triage](TRIAGE.md) — binary heavy-job classifier behaviour and tuning.
-- [Daemon mode](DAEMON.md) — persistent vs ephemeral daemons and the WebSocket protocol.
+ ---
-This site tracks the `main` branch. See the repository `CHANGELOG.md` for release history.
+ Trigger workflows from comments, labels, or natural language. See what each `bot:*` command does and how to stop one mid-flight.
+
+ [:octicons-arrow-right-24: Start with invocation](use/invoking.md)
+
+- :material-server:{ .lg .middle } __Run the service__
+
+ ---
+
+ Get from `git clone` to a webhook receiving production traffic. Configuration, deployment, observability, and runbooks for the most common Day-2 issues.
+
+ [:octicons-arrow-right-24: Start with setup](operate/setup.md)
+
+- :material-code-braces:{ .lg .middle } __Build on it__
+
+ ---
+
+ Architecture, request flow, and how to add a new workflow or MCP server. Conventions and contribution rules.
+
+ [:octicons-arrow-right-24: Start with architecture](build/architecture.md)
+
+
+
+This site tracks the `main` branch. Release history lives in the [changelog](changelog.md).
diff --git a/docs/operate/configuration.md b/docs/operate/configuration.md
new file mode 100644
index 00000000..f1824bdf
--- /dev/null
+++ b/docs/operate/configuration.md
@@ -0,0 +1,134 @@
+# Configuration reference
+
+Every environment variable the app reads at startup, grouped by concern. The authoritative source is `src/config.ts` — values are validated via Zod at boot and the process exits if a required variable is missing or malformed.
+
+**Default** is the fallback when the variable is unset (blank means "no default — must be set when required"). **Required when** is the runtime condition under which the variable is mandatory.
+
+## GitHub App credentials
+
+Server mode only. If `ORCHESTRATOR_URL` is set, the process runs in daemon mode and these are not required.
+
+| Variable | Default | Required when | Notes |
+| ------------------------ | ------- | ------------- | ----------------------------------------------------------------- |
+| `GITHUB_APP_ID` | — | Server mode | Numeric App ID from the App settings page. |
+| `GITHUB_APP_PRIVATE_KEY` | — | Server mode | Full PEM. Literal `\n` sequences are normalised to real newlines. |
+| `GITHUB_WEBHOOK_SECRET` | — | Server mode | HMAC-SHA256 secret configured in the App settings. |
+
+## AI provider
+
+| Variable | Default | Required when | Notes |
+| ---------------------------- | ------------------------------------------ | -------------------------------------------------- | ------------------------------------------------------------------------- |
+| `CLAUDE_PROVIDER` | `anthropic` | — | `anthropic` or `bedrock`. |
+| `CLAUDE_MODEL` | `claude-opus-4-7` (anthropic); — (bedrock) | Bedrock | Bedrock requires an explicit Bedrock model ID. |
+| `ANTHROPIC_API_KEY` | — | Anthropic, unless `CLAUDE_CODE_OAUTH_TOKEN` is set | Console pay-as-you-go. Safe for multi-tenant deploys. |
+| `CLAUDE_CODE_OAUTH_TOKEN` | — | Anthropic, unless `ANTHROPIC_API_KEY` is set | Max/Pro subscription token (`sk-ant-oat…`). Requires `ALLOWED_OWNERS`. |
+| `AWS_REGION` | — | Bedrock | Resolved by the AWS SDK credential chain. |
+| `AWS_PROFILE` | — | Optional (bedrock) | Local SSO profile for dev. |
+| `AWS_ACCESS_KEY_ID` | — | Optional (bedrock) | Long-lived credential pair. Prefer profile or OIDC. |
+| `AWS_SECRET_ACCESS_KEY` | — | Optional (bedrock) | Paired with `AWS_ACCESS_KEY_ID`. |
+| `AWS_SESSION_TOKEN` | — | Optional (bedrock) | Temporary credentials. |
+| `AWS_BEARER_TOKEN_BEDROCK` | — | Optional (bedrock, CI) | Set automatically by `aws-actions/configure-aws-credentials` OIDC. |
+| `ANTHROPIC_BEDROCK_BASE_URL` | — | Optional (bedrock) | Override Bedrock runtime endpoint (VPC endpoint / proxy). |
+| `ALLOWED_OWNERS` | — | OAuth token path | Comma-separated allowlist. Required when using `CLAUDE_CODE_OAUTH_TOKEN`. |
+
+## HTTP server
+
+| Variable | Default | Notes |
+| ------------------------- | ---------------------------- | ------------------------------------------------------------------------------------------------------- |
+| `PORT` | `3000` | HTTP webhook listener. |
+| `LOG_LEVEL` | `info` | Pino level: `fatal`, `error`, `warn`, `info`, `debug`, `trace`. `debug` surfaces full webhook payloads. |
+| `NODE_ENV` | `production` | `production`, `development`, `test`. |
+| `TRIGGER_PHRASE` | `@chrisleekr-bot` | Mention text that triggers the bot. Local dev typically sets `@chrisleekr-bot-dev`. |
+| `BOT_APP_LOGIN` | `chrisleekr-bot[bot]` | Bot's GitHub login. Used by the loop-prevention check. |
+| `MAX_CONCURRENT_REQUESTS` | `3` | Ceiling on simultaneous Claude executions across the fleet. |
+| `AGENT_TIMEOUT_MS` | `3600000` | Wall-clock budget for one agent execution (60 min). Lower only when the job is bounded. |
+| `AGENT_MAX_TURNS` | unset | Optional Claude SDK turn cap. Unset = no cap. Overrides `DEFAULT_MAXTURNS`. |
+| `DEFAULT_MAXTURNS` | unset | Process-wide turn cap. Set only if ops needs a hard ceiling. |
+| `CLAUDE_CODE_PATH` | resolved from `node_modules` | Absolute path to the Claude Code CLI `cli.js`. |
+| `CLONE_BASE_DIR` | `/tmp/bot-workspaces` | Parent directory for per-delivery clones. |
+| `CLONE_DEPTH` | `50` | Shallow-clone depth. Increase for deeply-diverged PRs. |
+| `CONTEXT7_API_KEY` | unset | Lifts Context7 MCP rate limiting. No other effect. |
+
+## Postgres
+
+Required whenever the orchestrator role is active.
+
+| Variable | Default | Notes |
+| -------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `DATABASE_URL` | — | Postgres connection. Backs `executions`, `triage_results`, `workflow_runs`, `ship_intents`, `ship_iterations`, `ship_continuations`, `ship_fix_attempts`, `repo_memory`, `daemons`. |
+
+## Valkey
+
+Required whenever the orchestrator role is active.
+
+| Variable | Default | Notes |
+| ------------ | ------- | ----------------------------------------------------------------------------------------------------------------------------- |
+| `VALKEY_URL` | — | Backs the daemon job queue, in-flight set, the ephemeral-spawn cooldown, the `ship:tickle` sorted set, and ship cancel flags. |
+
+## Orchestrator and daemon
+
+| Variable | Default | Notes |
+| ------------------------------ | --------------------- | ----------------------------------------------------------------------------------------------------- |
+| `WS_PORT` | `3002` | Orchestrator WebSocket listener. Must differ from `PORT`. |
+| `ORCHESTRATOR_URL` | — | Presence flips the process to daemon mode. Use `wss://` in production; `ws://` emits a warning. |
+| `ORCHESTRATOR_PUBLIC_URL` | — | Public WebSocket URL the spawner injects into ephemeral Pods. |
+| `DAEMON_AUTH_TOKEN` | — | Shared secret for the daemon ⇄ orchestrator handshake. Required on both sides. |
+| `HEARTBEAT_INTERVAL_MS` | `30000` | Daemon → orchestrator ping cadence. |
+| `HEARTBEAT_TIMEOUT_MS` | `90000` | Eviction threshold. Keep `≥ 2 × HEARTBEAT_INTERVAL_MS`. |
+| `STALE_EXECUTION_THRESHOLD_MS` | `3600000` | How long a `running` execution may sit before the watcher fails it. Set `≥ AGENT_TIMEOUT_MS`. |
+| `DAEMON_DRAIN_TIMEOUT_MS` | `300000` | Post-`SIGTERM` window to finish in-flight work. Raise to `≥ AGENT_TIMEOUT_MS` for zero mid-run kills. |
+| `JOB_MAX_RETRIES` | `3` | Retries for transient daemon dispatch failures. |
+| `OFFER_TIMEOUT_MS` | `5000` | How long the orchestrator waits for a daemon to claim an offer. |
+| `QUEUE_WORKER_BACKOFF_MAX_MS` | `5000` | Upper bound on the queue-worker's sleep when no local daemon can take a job. |
+| `LIVENESS_REAPER_INTERVAL_MS` | `30000` (min `20000`) | Cadence of the heartbeat-based reaper. |
+| `DAEMON_UPDATE_STRATEGY` | `exit` | `exit`, `pull`, or `notify`. Advisory hint reported in the update response. |
+| `DAEMON_UPDATE_DELAY_MS` | `0` | Delay before graceful shutdown after an update signal. |
+| `DAEMON_MEMORY_FLOOR_MB` | `512` | Minimum free memory the orchestrator requires before dispatching. |
+| `DAEMON_DISK_FLOOR_MB` | `1024` | Minimum free disk the orchestrator requires before dispatching. |
+
+## Ephemeral daemons
+
+Used when the orchestrator scales daemon capacity on demand.
+
+| Variable | Default | Notes |
+| ---------------------------------------- | ----------------- | ------------------------------------------------------------------------------------------------ |
+| `DAEMON_EPHEMERAL` | `false` | Set to `true` on ephemeral daemon Pods (injected by the spawner). Controls idle-exit. |
+| `EPHEMERAL_DAEMON_IDLE_TIMEOUT_MS` | `120000` | Ephemeral daemon exits after this idle window. |
+| `EPHEMERAL_DAEMON_SPAWN_COOLDOWN_MS` | `30000` | Minimum time between ephemeral spawns (orchestrator side). |
+| `EPHEMERAL_DAEMON_SPAWN_QUEUE_THRESHOLD` | `3` | Queue length that triggers an `ephemeral-daemon-overflow` spawn. |
+| `EPHEMERAL_DAEMON_NAMESPACE` | `default` | Kubernetes namespace for spawned ephemeral Pods. |
+| `DAEMON_IMAGE` | auto-detected | K8s image URI override. |
+| `KUBECONFIG` | auto (in-cluster) | Kubernetes client config path. The client auto-detects in-cluster via `KUBERNETES_SERVICE_HOST`. |
+
+The orchestrator also expects a pre-existing `daemon-secrets` Kubernetes Secret in `EPHEMERAL_DAEMON_NAMESPACE`, mounted into the spawned Pod via `envFrom: secretRef: daemon-secrets`. See [`deployment.md`](deployment.md#ephemeral-daemon-kubernetes-requirements).
+
+## Triage
+
+| Variable | Default | Notes |
+| ----------------------------- | ----------- | ------------------------------------------------------------------------------------------------------ |
+| `TRIAGE_ENABLED` | `true` | Kill-switch. When `false`, triage returns `heavy=false` and the job routes to `persistent-daemon`. |
+| `TRIAGE_MODEL` | `haiku-3-5` | Alias resolved at runtime. |
+| `TRIAGE_CONFIDENCE_THRESHOLD` | `1.0` | Below this, triage is treated as sub-threshold and the job routes to `persistent-daemon`. |
+| `TRIAGE_MAX_TOKENS` | `256` | Cap on the JSON response. Above ~100 is wasted budget. |
+| `TRIAGE_TIMEOUT_MS` | `5000` | Per-call wall clock. Beyond this, the circuit-breaker counter increments. |
+| `INTENT_CONFIDENCE_THRESHOLD` | `0.75` | Range `[0, 1]`. Below this, a mention-driven comment gets a clarification reply instead of a dispatch. |
+
+## Ship
+
+| Variable | Default | Notes |
+| --------------------------------- | ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------- |
+| `MAX_WALL_CLOCK_PER_SHIP_RUN` | `4h` | Hard ceiling on a single intent's wall-clock budget. Accepts ms or `Nh` / `Nm` / `Ns`. Per-invocation `--deadline` is clamped to this. |
+| `MAX_SHIP_ITERATIONS` | `50` | Iteration cap. Firing transitions the intent to terminal `human_took_over` with `terminal_blocker_category='iteration-cap'`. |
+| `CRON_TICKLE_INTERVAL_MS` | `30000` | How often the cron tickle scans `ship:tickle` for due intents. |
+| `MERGEABLE_NULL_BACKOFF_MS_LIST` | `500,1500,4500` | Comma-separated bounded backoff schedule used by the probe when `mergeable=null`. Exhaustion yields `mergeable_pending` and the session yields. |
+| `REVIEW_BARRIER_SAFETY_MARGIN_MS` | `1200000` (20 min) | Minimum elapsed time since the last bot push before the bot may declare `ready` without a non-bot review on the current head SHA. |
+| `FIX_ATTEMPTS_PER_SIGNATURE_CAP` | `3` | Max attempts per failure signature within a single intent. Cap firing terminates with `terminal_blocker_category='flake-cap'`. |
+| `SHIP_FORBIDDEN_TARGET_BRANCHES` | empty | Comma-separated branches the bot refuses to shepherd PRs against. |
+
+## Mode matrix — what's required when
+
+| Role | Required |
+| --------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
+| Orchestrator (webhook server) | GitHub App credentials, one AI provider credential, `VALKEY_URL`, `DATABASE_URL`, `DAEMON_AUTH_TOKEN`. |
+| Ephemeral-daemon scale-up | K8s API access + RBAC on `pods` in `EPHEMERAL_DAEMON_NAMESPACE`, `daemon-secrets` Secret. |
+| Daemon process (`ORCHESTRATOR_URL` set) | `DAEMON_AUTH_TOKEN`, one AI provider credential. GitHub App credentials and data-layer URLs are NOT required. |
diff --git a/docs/operate/deployment.md b/docs/operate/deployment.md
new file mode 100644
index 00000000..52219891
--- /dev/null
+++ b/docs/operate/deployment.md
@@ -0,0 +1,252 @@
+# Deployment
+
+The repository ships **two container images** — an orchestrator and a daemon — built from separate Dockerfiles that share a byte-identical base.
+
+## Image topology
+
+| Image | Dockerfile | Role | Outbound network |
+| -------------- | ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- |
+| `orchestrator` | `Dockerfile.orchestrator` | Webhook server, WebSocket daemon registry, triage classifier, ephemeral-daemon spawner. | GitHub API, Anthropic / Bedrock, Postgres, Valkey, K8s API. |
+| `daemon` | `Dockerfile.daemon` | Worker image with the toolchain Claude shells out to (`kubectl`, `helm`, `terraform`, `aws`, `gcloud`, `docker`, `go`, `rust`, …). | Orchestrator WebSocket (outbound), GitHub API, Anthropic. |
+
+The two images intentionally diverge after the shared base because their cost and attack surface differ. The shared prefix is enforced byte-identical by `scripts/check-dockerfile-base-sync.ts` (in CI) between the `# --- SHARED-BASE-BEGIN ---` and `# --- SHARED-BASE-END ---` markers.
+
+### Shared base stages
+
+| Stage | Base | Purpose |
+| ------------- | ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
+| `base` | `oven/bun:1.3.13` | Installs Node.js 20 (for the Claude Code CLI), npm 11, `curl`, `git`, `@anthropic-ai/claude-code` globally, plus targeted openssl CVE upgrades. |
+| `development` | `base` | `bun install` (all deps) + `bun run build` → `dist/` (app, daemon main, MCP stdio servers). |
+| `deps` | `base` | `bun install --production --ignore-scripts` (runtime deps only). |
+
+### Orchestrator-only stage
+
+| Stage | Base | Purpose |
+| ------------ | ------ | ------------------------------------------------------------------------------------ |
+| `production` | `base` | Copies `dist/`, production `node_modules/`, and `src/db/migrations/`. Runs as `bun`. |
+
+### Daemon-only stages
+
+| Stage | Base | Purpose |
+| -------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `daemon-tools` | `base` | Installs the full toolchain — kubectl, helm, terraform, kustomize, k9s, stern, argocd, flux, tflint, yq, aws-cli, gcloud, docker CLI, go, rust, poetry, gh, azure-cli — and bakes `daemon-capabilities.static.json` for fast startup. |
+| `production` | `daemon-tools` | Copies `dist/` and production `node_modules/`. Runs as `bun`. |
+
+Tool versions are parameterised by `ARG` (`KUBECTL_VERSION`, `HELM_VERSION`, etc.) and bumped together by Renovate/Dependabot. The Trivy scan in CI gates CVE regressions.
+
+## Build
+
+```bash
+bun run docker:build:orchestrator # → chrisleekr/github-app-playground:local-orchestrator
+bun run docker:build:daemon # → chrisleekr/github-app-playground:local-daemon
+bun run docker:build # both
+```
+
+There is no default `Dockerfile` — always pass `-f`.
+
+### Build arguments
+
+| Argument | Default | Purpose |
+| ----------------- | ------------- | ------------------------------------------------------------ |
+| `PACKAGE_VERSION` | `untagged` | Stored as Docker label `com.chrisleekr.bot.package-version`. |
+| `GIT_HASH` | `unspecified` | Stored as Docker label `com.chrisleekr.bot.git-hash`. |
+
+Daemon-only:
+
+| Argument | Default | Purpose |
+| ---------------- | ----------- | ----------------------------------------------------- |
+| `TARGETARCH` | from buildx | Selects amd64 / arm64 asset URLs. |
+| `INSTALL_GCLOUD` | `true` | Skip the ~500 MB Google Cloud SDK install if `false`. |
+| `INSTALL_LANGS` | `go rust` | Space-separated language toolchains. |
+
+```bash
+docker build -f Dockerfile.orchestrator \
+ --build-arg PACKAGE_VERSION=$(bun -e "console.log(require('./package.json').version)") \
+ --build-arg GIT_HASH=$(git rev-parse --short HEAD) \
+ -t chrisleekr/github-app-playground:$(git rev-parse --short HEAD)-orchestrator \
+ .
+```
+
+## Run
+
+### Orchestrator
+
+```bash
+docker run \
+ --env-file .env \
+ -p 3000:3000 \
+ -p 3002:3002 \
+ chrisleekr/github-app-playground:local-orchestrator
+```
+
+- `3000` — HTTP: webhook listener, `/healthz`, `/readyz`.
+- `3002` — WebSocket: daemon registry (`WS_PORT`). Expose only on networks the daemons connect from.
+
+Shortcut: `bun run docker:run:orchestrator` (mounts `~/.aws` read-only for local Bedrock testing).
+
+### Daemon
+
+```bash
+docker run \
+ --env-file .env \
+ -e ORCHESTRATOR_URL=ws://orchestrator-host:3002 \
+ -e DAEMON_AUTH_TOKEN=... \
+ -v $HOME/.aws:/home/bun/.aws:ro \
+ chrisleekr/github-app-playground:local-daemon
+```
+
+The daemon does **not** expose any HTTP port and does **not** need GitHub App credentials — the orchestrator mints installation tokens and hands them off per job.
+
+Shortcut: `bun run docker:run:daemon` (connects back to `ws://host.docker.internal:3002`).
+
+## Health and readiness probes
+
+Endpoints exist on the **orchestrator image only**. Daemon liveness is tracked via the WebSocket heartbeat in the orchestrator's daemon registry.
+
+| Endpoint | Method | Success | Failure | Purpose |
+| ---------- | ------ | ----------- | --------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
+| `/healthz` | GET | `200 ok` | — | Liveness — process is alive (no external deps). |
+| `/readyz` | GET | `200 ready` | `503 not ready` | Readiness — config validated and data layer reachable. Returns `503 not ready` during startup, when a dependency is down, or after `SIGTERM`. |
+
+`Dockerfile.orchestrator` ships with a Docker `HEALTHCHECK` invoking `curl -f http://localhost:3000/healthz`. Honoured by Docker Compose, ECS, Nomad, Swarm. Kubernetes ignores Docker `HEALTHCHECK` and uses the probe spec below.
+
+```yaml
+livenessProbe:
+ httpGet:
+ path: /healthz
+ port: 3000
+ initialDelaySeconds: 5
+ periodSeconds: 10
+
+readinessProbe:
+ httpGet:
+ path: /readyz
+ port: 3000
+ initialDelaySeconds: 5
+ periodSeconds: 5
+```
+
+For the daemon, replace HTTP probes with an `exec` probe that checks the WebSocket connection — see [`runbooks/daemon-fleet.md`](runbooks/daemon-fleet.md).
+
+## Graceful shutdown
+
+The orchestrator handles `SIGTERM` and `SIGINT`:
+
+1. Flips `/readyz` to `503` so the load balancer stops routing.
+2. Calls `server.close()` — waits for in-flight HTTP requests.
+3. MCP stdio child processes exit via their own `finally` blocks.
+4. Force-exits after 290 seconds if shutdown hasn't completed (`src/app.ts`).
+
+Set `terminationGracePeriodSeconds: 300` on the Pod so SIGKILL lands 10 seconds after the force-exit.
+
+The daemon has its own drain contract driven by `DAEMON_DRAIN_TIMEOUT_MS`: it finishes the current job, refuses new offers, then disconnects. Match `terminationGracePeriodSeconds` to `DAEMON_DRAIN_TIMEOUT_MS` on the daemon Pod.
+
+## Resource recommendations
+
+### Orchestrator
+
+I/O-bound — never runs the pipeline itself. 1 GB is typically enough.
+
+| `MAX_CONCURRENT_REQUESTS` | Memory | CPU |
+| ------------------------- | ------ | -------- |
+| 1 | 1 GB | 1 vCPU |
+| 3 (default) | 2 GB | 1–2 vCPU |
+| 5 | 3 GB | 2 vCPU |
+
+### Daemon
+
+Dominated by what Claude runs inside it (`kubectl`, `terraform plan`, `docker build`).
+
+| Concurrent jobs | Memory | CPU |
+| ------------------- | ------ | -------- |
+| 1 | 2 GB | 1–2 vCPU |
+| 3 (typical default) | 4 GB | 2–4 vCPU |
+
+The daemon image is ~2 GB unpacked. The same sizing applies to ephemeral daemon Pods spawned by the orchestrator (same image).
+
+### Disk
+
+Each job clones the target repo to `CLONE_BASE_DIR` (default `/tmp/bot-workspaces`) with `git clone --depth=${CLONE_DEPTH}` (default `50`). The directory is removed in the pipeline's `finally` block.
+
+Peak disk = `average_repo_size × concurrent_jobs`. For monorepos, mount a dedicated volume:
+
+```yaml
+volumes:
+ - name: bot-workspaces
+ emptyDir:
+ sizeLimit: 5Gi
+containers:
+ - name: github-app-playground
+ env:
+ - name: CLONE_BASE_DIR
+ value: /workspaces
+ volumeMounts:
+ - name: bot-workspaces
+ mountPath: /workspaces
+```
+
+## Ephemeral-daemon Kubernetes requirements
+
+If you want the orchestrator to spawn ephemeral daemon Pods on demand, two things must exist in `EPHEMERAL_DAEMON_NAMESPACE`.
+
+### Orchestrator RBAC
+
+```yaml
+apiVersion: rbac.authorization.k8s.io/v1
+kind: Role
+metadata:
+ name: github-app-playground-ephemeral-spawner
+ namespace: ${EPHEMERAL_DAEMON_NAMESPACE}
+rules:
+ - apiGroups: [""]
+ resources: ["pods"]
+ verbs: ["create", "get", "delete"]
+---
+apiVersion: rbac.authorization.k8s.io/v1
+kind: RoleBinding
+metadata:
+ name: github-app-playground-ephemeral-spawner
+ namespace: ${EPHEMERAL_DAEMON_NAMESPACE}
+subjects:
+ - kind: ServiceAccount
+ name: github-app-playground
+ namespace: ${ORCHESTRATOR_NAMESPACE}
+roleRef:
+ kind: Role
+ name: github-app-playground-ephemeral-spawner
+ apiGroup: rbac.authorization.k8s.io
+```
+
+Without these verbs every spawn yields `dispatch_reason=ephemeral-spawn-failed` and the job is rejected with a tracking-comment infra error.
+
+### `daemon-secrets` Secret
+
+Spawned ephemeral Pods get their config via `envFrom: secretRef: daemon-secrets`. Create this Secret once in `EPHEMERAL_DAEMON_NAMESPACE` with at minimum:
+
+- `DAEMON_AUTH_TOKEN` — daemon ⇄ orchestrator handshake. **Only source.** The spawner does not inline this into the Pod spec, so it cannot leak via `kubectl get pod -o yaml` or the Pod audit log.
+- `ANTHROPIC_API_KEY` or `CLAUDE_CODE_OAUTH_TOKEN` (and `ALLOWED_OWNERS`) or Bedrock `AWS_*` vars.
+- `VALKEY_URL`, `DATABASE_URL`.
+
+GitHub App private-key material is **not** placed in this Secret. The orchestrator mints installation tokens and hands them per-job, so blast radius does not need to expand to every ephemeral Pod. `ORCHESTRATOR_URL` is provided inline by the spawner from `ORCHESTRATOR_PUBLIC_URL`.
+
+### Ephemeral Pod security posture
+
+The spawner hardens every ephemeral Pod (see `src/k8s/ephemeral-daemon-spawner.ts`):
+
+- `automountServiceAccountToken: false` — the daemon never calls the K8s API itself.
+- Pod `securityContext`: `runAsNonRoot: true`, `runAsUser: 1000`, `runAsGroup: 1000`, `seccompProfile: RuntimeDefault`.
+- Container: `allowPrivilegeEscalation: false`, `capabilities.drop: [ALL]`.
+- `restartPolicy: Never` and `activeDeadlineSeconds: 3600` cap the Pod hard.
+
+## Production tunables worth double-checking
+
+The full schema lives at [`configuration.md`](configuration.md). At minimum:
+
+| Variable | Production recommendation |
+| ------------------------- | ------------------------------------------------------------ |
+| `NODE_ENV` | `production` |
+| `LOG_LEVEL` | `info` (`debug` exposes webhook payloads) |
+| `MAX_CONCURRENT_REQUESTS` | Start at `3`, tune against memory and LLM budget |
+| `AGENT_TIMEOUT_MS` | Stay below 3600 s — the GitHub installation-token TTL |
+| `CLONE_BASE_DIR` | Override if `/tmp` is small or shared |
+| `PORT`, `WS_PORT` | `3000`, `3002` (must match probes and the `WS_PORT` env var) |
diff --git a/docs/operate/github-app.md b/docs/operate/github-app.md
new file mode 100644
index 00000000..b93cadde
--- /dev/null
+++ b/docs/operate/github-app.md
@@ -0,0 +1,131 @@
+# Creating the GitHub App
+
+Step-by-step guide for registering, configuring, and installing the `@chrisleekr-bot` GitHub App. For local development after the App exists, see [`setup.md`](setup.md).
+
+## 1. Register the App
+
+### 1.1 Open the registration form
+
+Personal account: **Settings → Developer settings → GitHub Apps → New GitHub App**.
+Organization: **Org settings → Developer settings → GitHub Apps → New GitHub App**.
+
+Direct link: .
+
+> A user or organization can register up to 100 GitHub Apps. Source: [Registering a GitHub App](https://docs.github.com/en/apps/creating-github-apps/registering-a-github-app/registering-a-github-app).
+
+### 1.2 Basic information
+
+| Field | Value | Notes |
+| --------------- | -------------------------------------------------------------------------------------- | ---------------------------------------------------------------- |
+| GitHub App name | `chrisleekr-bot` | Globally unique, ≤ 34 chars, slugified to lowercase-with-dashes. |
+| Homepage URL | `https://github.com/chrisleekr/github-app-playground` | Required; any valid HTTPS URL. |
+| Description | `AI-powered code review bot — responds to @chrisleekr-bot mentions on PRs and issues.` | Optional. |
+
+### 1.3 Webhook configuration
+
+| Field | Value |
+| ---------------- | ------------------------------------------------------------------ |
+| Active | ✅ |
+| Webhook URL | `https:///api/github/webhooks` |
+| SSL verification | Enabled (default — keep it). |
+| Webhook secret | Output of `openssl rand -hex 32`. Save as `GITHUB_WEBHOOK_SECRET`. |
+
+The path `/api/github` is set by `pathPrefix` in `createNodeMiddleware` (`src/app.ts`). Don't change the path unless you also change the source.
+
+For local dev, use a tunnel:
+
+```bash
+bun run dev:ngrok
+# or
+smee --url https://smee.io/ --path /api/github/webhooks --port 3000
+```
+
+### 1.4 OAuth, Setup, post-install
+
+Leave all OAuth, callback URL, Device Flow, and Setup URL fields empty/unchecked. This App uses **installation tokens only** (server-to-server) and never acts on behalf of an individual user. See [About authentication with a GitHub App](https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/about-authentication-with-a-github-app).
+
+### 1.5 Permissions
+
+Repository permissions:
+
+| Permission | Setting | Why |
+| ------------- | ------------ | --------------------------------------------------------------------- |
+| Actions | Read-only | Read workflow run state for `bot:resolve` CI fixes. |
+| Contents | Read & Write | Clone repos and push commits via the git CLI. |
+| Issues | Read & Write | Read issue body / comments; post bot replies. |
+| Pull requests | Read & Write | Read PR diff and context; post review comments and replies. |
+| Metadata | Read-only | Auto-granted; required for all GitHub Apps. |
+| Workflows | Read & Write | Modify `.github/workflows/*.yml` when an `implement` task touches CI. |
+
+Leave all organisation and account permissions at **No access**. Principle of least privilege — see [Choosing permissions for a GitHub App](https://docs.github.com/en/apps/creating-github-apps/setting-up-a-github-app/choosing-permissions-for-a-github-app).
+
+### 1.6 Subscribe to events
+
+| Checkbox | Actions handled | Handler |
+| ---------------------------- | --------------------------------------------------------------- | -------------------------------------- |
+| Issue comments | `issue_comment.created` | `src/webhook/events/issue-comment.ts` |
+| Issues | `issues.labeled`, `issues.unlabeled` | `src/webhook/events/issues.ts` |
+| Pull requests | `pull_request.opened` / `.labeled` / `.synchronize` / `.closed` | `src/webhook/events/pull-request.ts` |
+| Pull request reviews | `pull_request_review.submitted` | `src/webhook/events/review.ts` |
+| Pull request review comments | `pull_request_review_comment.created` / `.edited` / `.deleted` | `src/webhook/events/review-comment.ts` |
+| Pull request review threads | `pull_request_review_thread.resolved` / `.unresolved` | `src/webhook/events/review-thread.ts` |
+| Check runs | `check_run.completed` | `src/webhook/events/check-run.ts` |
+| Check suites | `check_suite.completed` | `src/webhook/events/check-suite.ts` |
+
+The shepherding reactor uses `synchronize`, `closed`, `edited`, `deleted`, `check_run`, and `check_suite` to early-wake active sessions on `Valkey ZADD ship:tickle`. Every subscribed event you do not handle still hits your webhook URL — keep this list tight.
+
+> GitHub does not emit a `pull_request_review_thread.created` action. The only valid actions for that event are `resolved` and `unresolved`.
+
+### 1.7 Install scope
+
+| Option | Use when |
+| -------------------- | -------------------------------------------------------- |
+| Only on this account | Personal or single-org private deployment (recommended). |
+| Any account | You plan to share the App publicly. |
+
+Click **Create GitHub App**. GitHub assigns the **App ID** and redirects to the App's General settings.
+
+## 2. Generate a private key
+
+On the App's General settings:
+
+1. Scroll to **Private keys**.
+2. Click **Generate a private key** — GitHub immediately downloads `chrisleekr-bot.YYYY-MM-DD.private-key.pem`.
+3. Move it to a password manager or secrets vault. **Never commit it.**
+
+The full PEM (including `-----BEGIN…` / `-----END…` lines) is the value of `GITHUB_APP_PRIVATE_KEY`. Single-line `.env` form:
+
+```bash
+GITHUB_APP_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\n\n-----END RSA PRIVATE KEY-----\n"
+```
+
+Or read from disk:
+
+```bash
+export GITHUB_APP_PRIVATE_KEY="$(awk 'NF {sub(/\r/, ""); printf "%s\\n",$0;}' chrisleekr-bot.private-key.pem)"
+```
+
+See [Managing private keys for GitHub Apps](https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/managing-private-keys-for-github-apps) for rotation.
+
+## 3. Note the App ID
+
+On the General settings page, the **About** section shows the numeric **App ID** (e.g. `123456`). Save it as `GITHUB_APP_ID`.
+
+## 4. Install on repositories
+
+In the App settings sidebar, click **Install App**, then **Install** next to the target account. Choose:
+
+| Option | Effect |
+| ------------------------ | ------------------------------------------------------------- |
+| All repositories | Access to every current and future repository on the account. |
+| Only select repositories | Limited to repositories you explicitly choose (recommended). |
+
+After installation, the bot only responds to mentions in repositories where the App is installed.
+
+## 5. Verify
+
+1. **Settings → Developer settings → GitHub Apps → your app → Advanced** — redeliver a recent webhook.
+2. Open an issue in an installed repository and post `@chrisleekr-bot triage this` (or `@chrisleekr-bot-dev` locally).
+3. The bot posts a tracking comment within seconds.
+
+If it doesn't, see the troubleshooting table in [`setup.md`](setup.md#testing-webhook-delivery).
diff --git a/docs/operate/observability.md b/docs/operate/observability.md
new file mode 100644
index 00000000..1db5ac9b
--- /dev/null
+++ b/docs/operate/observability.md
@@ -0,0 +1,104 @@
+# Observability
+
+Structured JSON logs via [pino](https://getpino.io) are the primary signal. Every dispatch decision and every pipeline step carries a `deliveryId` so you can reconstruct a request end-to-end from a single log query. When `DATABASE_URL` is configured, the same information is persisted to `executions` and `triage_results` for aggregate reporting.
+
+## Common log fields
+
+| Field | Meaning |
+| ------------------------------------ | ----------------------------------------------------------------------------------------------------- |
+| `deliveryId` | `X-GitHub-Delivery` header — stable across every log line for a single webhook. |
+| `event` | GitHub event name (`pull_request`, `issue_comment`, …) or canonical event key for ship workflow logs. |
+| `repo` | `owner/name` of the triggering repo. |
+| `dispatch_target` | Always `daemon` (singleton — kept as a field for DB/log stability). |
+| `dispatch_reason` | Why the job landed where it did. See [Dispatch reasons](#dispatch-reasons). |
+| `isEphemeral` | Present on daemon-originating log lines. `true` if emitted by an ephemeral daemon. |
+| `triage_fallback_reason` | Only present on triage fallbacks — see [`runbooks/triage.md`](runbooks/triage.md). |
+| `confidence`, `heavy`, `rationale` | Triage outputs on success. |
+| `cost_usd` | Agent-reported total cost from the SDK. |
+| `workflowRunId`, `workflowName` | UUID of the `workflow_runs` row + workflow name. Stable per run. |
+| `intentWorkflow`, `intentConfidence` | Intent-classifier verdict and confidence for comment triggers. |
+
+## Ship workflow log fields
+
+The shepherding lifecycle emits structured pino lines validated against the canonical Zod schema in `src/workflows/ship/log-fields.ts`. Field names and types are pinned so emitters cannot drift.
+
+| Field | Type | When present |
+| --------------------------- | ------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
+| `event` | string (e.g. `ship.intent.transition`, `ship.probe.run`, `ship.reactor.fanout`) | Always. |
+| `intent_id` | UUID | Always. |
+| `pr` | `{owner, repo, number, installation_id}` | Always. |
+| `iteration_n` | non-negative int | Always (0 on pre-iteration events). |
+| `phase` | `probe` \| `fix` \| `reply` \| `wait` \| `terminal` | Iteration events. |
+| `from_status` / `to_status` | session status | Transition events only. |
+| `terminal_blocker_category` | blocker category | Terminal `human_took_over` transitions. |
+| `non_readiness_reason` | enum | Probe events with non-ready verdict. |
+| `trigger_surface` | `literal` \| `nl` \| `label` | Session-start events only. |
+| `principal_login` | string | Session-start events only. |
+| `spent_usd_cents` | non-negative integer | Always — cumulative session spend in cents (integer to avoid binary-fp drift in aggregations). |
+| `wall_clock_ms` | non-negative integer | Always — cumulative session wall-clock. |
+| `delta_usd_cents` | non-negative integer | Per-event spend (iteration events only). |
+| `delta_ms` | non-negative integer | Per-event wall-clock duration. |
+
+The schema is the source of truth. Adding or renaming a field requires updating `src/workflows/ship/log-fields.ts`; the co-located test round-trips a sample through the schema and rejects unknown / mistyped fields.
+
+### Iteration / tickle / scoped event keys
+
+Every shepherding emitter draws its `event` value from the typed `SHIP_LOG_EVENTS` constant in `src/workflows/ship/log-fields.ts`. Operators can grep for these literals deterministically.
+
+| Event key | Where it fires | What it indicates |
+| ------------------------------------- | --------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- |
+| `ship.iteration.enqueued` | `iteration.runIteration` after `enqueueJob` | A non-ready verdict bridged into the daemon `workflow_runs` pipeline. One row per iteration. |
+| `ship.iteration.terminal_cap` | `iteration.runIteration` cap check | The intent hit `MAX_SHIP_ITERATIONS`. |
+| `ship.iteration.terminal_deadline` | `iteration.runIteration` deadline check | The intent's `deadline_at` elapsed. |
+| `ship.tickle.started` | `app.ts` boot, after `tickleScheduler.start()` | The cron tickle scheduler is scanning `ship:tickle`. |
+| `ship.tickle.due` | `orchestrator.onStepComplete` early-wake **or** `session-runner.resumeShipIntent` | An intent is being re-entered. `source` discriminates `workflow_run_completion` vs scheduler. |
+| `ship.tickle.skip_terminal` | `orchestrator.onStepComplete` early-wake | The hook found a `shipIntentId` but the intent is already terminal; the ZADD was skipped. |
+| `ship.scoped..enqueued` | `dispatch-scoped.ts` after `enqueueJob` | A scoped command (`rebase`, `fix_thread`, `explain_thread`, `open_pr`) was enqueued. |
+| `ship.scoped..daemon.completed` | `connection-handler.handleScopedJobCompletion` and the executor | Daemon reported `succeeded`. |
+| `ship.scoped..daemon.failed` | Same | Daemon reported `halted` or `failed`. `reason` carries the structured halt reason. |
+
+### Querying example (Datadog / Loki)
+
+```text
+event:"ship.intent.transition" to_status:"human_took_over" terminal_blocker_category:"flake-cap"
+| count by pr.repo
+```
+
+## Dispatch reasons
+
+Canonical source: `src/shared/dispatch-types.ts`. Four values; all land on `dispatch_target=daemon`.
+
+| Reason | When the router sets it |
+| --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `persistent-daemon` | Routed to an existing persistent daemon. The default, hot path. Also used during cooldown when a scale-up was warranted but blocked by the cooldown window. |
+| `ephemeral-daemon-triage` | Triage returned `heavy=true` and an ephemeral daemon Pod was spawned. |
+| `ephemeral-daemon-overflow` | Queue length ≥ `EPHEMERAL_DAEMON_SPAWN_QUEUE_THRESHOLD` **and** the persistent pool has zero free slots; a spawn drains the overflow. |
+| `ephemeral-spawn-failed` | A spawn was required but the K8s API call failed. The job is rejected with a tracking-comment infra error. |
+
+## Aggregate reporting
+
+When `DATABASE_URL` is set, helpers in `src/db/queries/dispatch-stats.ts` expose the most operator-relevant aggregates:
+
+| Helper | Returns |
+| -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `eventsPerTarget(days)` | Count of executions grouped by `dispatch_target`. Post-collapse this is always a single `daemon` row — query `dispatch_reason` directly for the per-reason split. |
+| `triageRate(days)` | Share of events whose `dispatch_reason` is `ephemeral-daemon-triage`. |
+| `avgConfidenceAndFallback(days)` | Mean triage confidence plus fallback counts by reason. |
+| `triageSpend(days)` | Cumulative `cost_usd` for triage-reached executions. |
+
+Call them from an internal admin endpoint, a scheduled job, or `bun repl`.
+
+## Alerts worth having
+
+- **Triage error rate.** `parse-error` + `llm-error` + `timeout` + `circuit-open` above a sustained threshold (e.g. 10 % over 15 minutes) signals provider trouble or a regression.
+- **Ephemeral spawn failures.** Any `dispatch_reason=ephemeral-spawn-failed` points at RBAC, quota, or control-plane issues.
+- **Heartbeat drift.** Daemons missing heartbeats past `HEARTBEAT_TIMEOUT_MS` get evicted; sustained eviction points at network or resource-floor issues.
+- **OOM / crash loops.** Standard infra alerts. Durable idempotency means a restart will not replay a processed event.
+- **Ship terminal-blocker rate.** A spike in `ship.intent.transition` events with `to_status:human_took_over` and `terminal_blocker_category:flake-cap` points at PR-flake regressions, not bot misbehaviour.
+
+## Health probes
+
+| Path | Purpose |
+| ---------- | ---------------------------------------------------------------------------------------------------- |
+| `/healthz` | Liveness — returns 200 once the HTTP server is bound. |
+| `/readyz` | Readiness — 200 once config is validated and the data layer is reachable; flips to 503 on `SIGTERM`. |
diff --git a/docs/operate/runbooks/daemon-fleet.md b/docs/operate/runbooks/daemon-fleet.md
new file mode 100644
index 00000000..f32cc1e1
--- /dev/null
+++ b/docs/operate/runbooks/daemon-fleet.md
@@ -0,0 +1,136 @@
+# Runbook — daemon fleet
+
+A daemon is a standalone worker process that connects to the orchestrator over WebSocket, accepts job offers, and runs each job through `src/core/pipeline.ts`. The webhook server never runs the pipeline in-process — every execution happens on a daemon.
+
+## Persistent vs ephemeral
+
+Always qualify which kind you mean. The union of both at any given moment is the **daemon fleet**.
+
+| Type | How it starts | Lifetime | `DAEMON_EPHEMERAL` |
+| -------------- | -------------------------------------------------------------------- | -------------------------------------------------------------------------------- | ------------------ |
+| **Persistent** | Deployed out-of-band (Helm, kubectl, `docker run`, systemd). | Long-lived; stays connected until `SIGTERM` or eviction. | unset / `false` |
+| **Ephemeral** | Spawned on demand by the orchestrator as a bare Pod via the K8s API. | Exits after `EPHEMERAL_DAEMON_IDLE_TIMEOUT_MS` (default 120 s) of no active job. | `true` |
+
+Only persistent daemons count toward the "persistent pool free slots" the orchestrator uses to decide whether an overflow spawn is warranted. Ephemeral daemons exist specifically to drain the current surge and disappear.
+
+## Daemon lifecycle
+
+```mermaid
+flowchart LR
+ Boot["Process start"]:::start
+ Connect["WebSocket connect to ORCHESTRATOR_URL
Bearer DAEMON_AUTH_TOKEN"]:::work
+ Register["daemon register
capabilities + resources + isEphemeral"]:::work
+ Idle["Idle wait"]:::wait
+ Offer["job offer or scoped-job-offer"]:::work
+ Eval{{"Capacity check
memory floor + disk floor + slot free"}}:::fork
+ Accept["job accept"]:::work
+ Reject["job reject
with reason"]:::halt
+ Run["src/core/pipeline.ts
clone -> agent -> push -> cleanup"]:::work
+ Result["job result or scoped-job completion"]:::work
+ Drain["Drain on SIGTERM
refuse new offers"]:::wait
+ Exit["Exit"]:::done
+ IdleExit["Ephemeral idle exit
after EPHEMERAL_DAEMON_IDLE_TIMEOUT_MS"]:::done
+
+ Boot --> Connect --> Register --> Idle
+ Idle --> Offer --> Eval
+ Eval -->|fits| Accept --> Run --> Result --> Idle
+ Eval -->|no| Reject --> Idle
+ Idle -. SIGTERM .-> Drain --> Exit
+ Idle -. ephemeral idle .-> IdleExit
+
+ classDef start fill:#0b5cad,stroke:#083e74,color:#ffffff
+ classDef work fill:#114a82,stroke:#0a2f56,color:#ffffff
+ classDef fork fill:#6a2080,stroke:#451454,color:#ffffff
+ classDef wait fill:#5c3d00,stroke:#3d2900,color:#ffffff
+ classDef halt fill:#852020,stroke:#5a1414,color:#ffffff
+ classDef done fill:#2a6f2a,stroke:#1a4d1a,color:#ffffff
+```
+
+## Operational knobs
+
+The full list lives at [`../configuration.md`](../configuration.md#orchestrator-and-daemon). The handful you'll actually touch:
+
+| Variable | Default | Notes |
+| ---------------------------------- | -------- | ---------------------------------------------------------------------------------- |
+| `ORCHESTRATOR_URL` | — | Required. `wss://` in production; `ws://` emits a warning. |
+| `DAEMON_AUTH_TOKEN` | — | Shared secret with the orchestrator. |
+| `DAEMON_EPHEMERAL` | `false` | `true` on ephemeral daemon Pods (injected by the spawner). Enables idle-exit. |
+| `EPHEMERAL_DAEMON_IDLE_TIMEOUT_MS` | `120000` | Ephemeral daemons exit after this idle window. |
+| `HEARTBEAT_INTERVAL_MS` | `30000` | Ping cadence. |
+| `HEARTBEAT_TIMEOUT_MS` | `90000` | Orchestrator eviction threshold. Keep `≥ 2 × HEARTBEAT_INTERVAL_MS`. |
+| `DAEMON_DRAIN_TIMEOUT_MS` | `300000` | Post-`SIGTERM` grace. Raise to `≥ AGENT_TIMEOUT_MS` to guarantee no mid-run kills. |
+| `DAEMON_MEMORY_FLOOR_MB` | `512` | Below this, the orchestrator skips the daemon on dispatch. |
+| `DAEMON_DISK_FLOOR_MB` | `1024` | Same, for free disk. |
+
+## Persistent daemon Deployment
+
+```yaml
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: github-app-playground-daemon
+ namespace: default
+spec:
+ replicas: 2
+ selector:
+ matchLabels:
+ app: github-app-playground-daemon
+ template:
+ metadata:
+ labels:
+ app: github-app-playground-daemon
+ spec:
+ terminationGracePeriodSeconds: 300
+ containers:
+ - name: daemon
+ image: chrisleekr/github-app-playground:latest-daemon
+ envFrom:
+ - secretRef:
+ name: daemon-secrets
+ env:
+ - name: ORCHESTRATOR_URL
+ value: "wss://orchestrator.example.internal:3002"
+ - name: CLONE_BASE_DIR
+ value: "/workspaces"
+ volumeMounts:
+ - name: bot-workspaces
+ mountPath: /workspaces
+ volumes:
+ - name: bot-workspaces
+ emptyDir:
+ sizeLimit: 5Gi
+```
+
+Match `terminationGracePeriodSeconds` to `DAEMON_DRAIN_TIMEOUT_MS` so `SIGTERM` has time to drain in-flight work before `SIGKILL`.
+
+## Concurrency and scaling
+
+A daemon process handles up to its advertised `maxConcurrentJobs` at a time. Scale **horizontally** by running multiple persistent daemon pods. The orchestrator adds ephemeral daemons for bursts (triage `heavy=true` or queue overflow) — see [`../observability.md`](../observability.md#dispatch-reasons).
+
+### Scale-up rule
+
+On every event the orchestrator evaluates:
+
+1. **Triage.** A single-turn Haiku call returns `{heavy, confidence, rationale}`. `heavy=true` is one trigger.
+2. **Overflow.** If `queue_length ≥ EPHEMERAL_DAEMON_SPAWN_QUEUE_THRESHOLD` **and** the persistent pool has zero free slots, that's the other trigger.
+3. **Cooldown.** Spawns are rate-limited by `EPHEMERAL_DAEMON_SPAWN_COOLDOWN_MS`. During cooldown, heavy/overflow signals **don't** spawn — the job falls back to `persistent-daemon` and waits.
+4. **Spawn.** When both a trigger fires and cooldown has elapsed, the orchestrator creates a bare Pod via the K8s API. A K8s API failure yields `dispatch_reason=ephemeral-spawn-failed` and the job is rejected with a tracking-comment infra error.
+
+## Hard constraints
+
+- `AGENT_TIMEOUT_MS` must stay below the GitHub installation-token TTL (3600 s) so the daemon cannot outlive its credentials.
+- `EPHEMERAL_DAEMON_IDLE_TIMEOUT_MS` should be longer than typical heartbeat cadence so a short lull between back-to-back jobs does not cause a premature exit.
+- `terminationGracePeriodSeconds` on the daemon Pod should match `DAEMON_DRAIN_TIMEOUT_MS`.
+
+## Common Day-2 issues
+
+| Symptom | Likely cause |
+| -------------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
+| Sustained heartbeat eviction | Daemon CPU starvation, network partition, or `HEARTBEAT_TIMEOUT_MS` too low. |
+| `dispatch_reason=ephemeral-spawn-failed` | Missing RBAC on `pods` in `EPHEMERAL_DAEMON_NAMESPACE`, missing `daemon-secrets`, or a control-plane issue. |
+| Mid-run kills on rolling deploys | `terminationGracePeriodSeconds` < `DAEMON_DRAIN_TIMEOUT_MS`. |
+| `executions.status='running'` rows piling up | A daemon died abruptly. The `LIVENESS_REAPER_INTERVAL_MS` reaper flips them to failed; check daemon pod logs. |
+
+## Implementation references
+
+`src/daemon/main.ts`, `src/orchestrator/ws-server.ts`, `src/orchestrator/ephemeral-daemon-scaler.ts`, `src/k8s/ephemeral-daemon-spawner.ts`, `src/core/pipeline.ts`, `src/shared/ws-messages.ts`.
diff --git a/docs/operate/runbooks/stuck-ship-intent.md b/docs/operate/runbooks/stuck-ship-intent.md
new file mode 100644
index 00000000..2b8fb28f
--- /dev/null
+++ b/docs/operate/runbooks/stuck-ship-intent.md
@@ -0,0 +1,127 @@
+# Runbook — stuck `bot:ship` session
+
+A shepherding session that doesn't terminate cleanly leaves a row in `ship_intents` with status other than `ready_awaiting_human_merge` or `merged_externally`. This page is a guide to figuring out which class of stuck and what to do.
+
+## Database tables
+
+Two tables carry the bulk of operator-relevant state:
+
+| Table | Rows |
+| -------------------- | ---------------------------------------------------------------------------------------------------- |
+| `ship_intents` | One per session. Status, deadline, cumulative spend, terminal blocker category, tracking comment id. |
+| `ship_iterations` | One per probe / fix / reply / review iteration. Verdict on probe rows, cost, wall-clock. |
+| `ship_continuations` | One per active intent. `wake_at`, `state_blob`, `wait_for[]` array. |
+| `ship_fix_attempts` | Retry ledger keyed by `(intent_id, signature)`. Drives `flake-cap` enforcement. |
+
+Migration files live under `src/db/migrations/`. The ship lifecycle was added in `008_ship_intents.sql`.
+
+## Status values
+
+| Status | Meaning | Recoverable? |
+| ---------------------------- | ------------------------------------------------------ | ------------------------------------------- |
+| `active` | Session is in flight (or about to be tickled). | — |
+| `paused` | `bot:stop` issued. Deadline keeps counting. | Yes — `bot:resume`. |
+| `ready_awaiting_human_merge` | Probe verdict was `ready`; tracking comment finalised. | Terminal — human merge expected. |
+| `merged_externally` | PR was merged while session was active. | Terminal. |
+| `pr_closed` | PR was closed (not merged) while session was active. | Terminal. |
+| `human_took_over` | Foreign push detected, iteration cap, or flake cap. | Terminal — see `terminal_blocker_category`. |
+| `deadline_exceeded` | `MAX_WALL_CLOCK_PER_SHIP_RUN` elapsed. | Terminal. |
+| `aborted_by_user` | `bot:abort-ship` issued. | Terminal — no further mutations. |
+
+## Terminal blocker categories
+
+When `status='human_took_over'`:
+
+| Category | Meaning | Action |
+| ---------------------------- | ---------------------------------------------------------------------- | -------------------------------------------------------------- |
+| `manual-push-detected` | A non-bot principal pushed to the PR. | If you want the bot to take over again, re-trigger `bot:ship`. |
+| `iteration-cap` | The session ran `MAX_SHIP_ITERATIONS` rounds without resolving. | Re-scope the work or split the PR. |
+| `flake-cap` | Same failure signature retried `FIX_ATTEMPTS_PER_SIGNATURE_CAP` times. | Investigate the flake; the bot will not retry indefinitely. |
+| `merge-conflict-needs-human` | Rebase produced conflicts the bot would not resolve confidently. | Resolve manually, then re-trigger. |
+| `permission-denied` | A required GitHub mutation returned 403. | Check App permissions / repo collaborator access. |
+| `stopped-by-user` | Session was paused indefinitely. | `bot:resume` or `bot:abort-ship`. |
+| `unrecoverable-error` | Catch-all for unexpected pipeline failures. | Read `ship_iterations.verdict_json` for the last iteration. |
+| `design-discussion-needed` | Probe escalated when the agent flagged a non-mechanical decision. | Discuss in PR thread; re-trigger if direction is clear. |
+
+## Day-2 SQL queries
+
+All queries assume `psql` against `DATABASE_URL`.
+
+### Active sessions
+
+```sql
+SELECT
+ id,
+ owner || '/' || repo AS repo,
+ pr_number,
+ status,
+ deadline_at,
+ spent_usd,
+ EXTRACT(EPOCH FROM (now() - created_at))::int AS age_seconds
+FROM ship_intents
+WHERE status IN ('active', 'paused')
+ORDER BY created_at;
+```
+
+### Terminal-state distribution (last 7 days)
+
+```sql
+SELECT
+ status,
+ terminal_blocker_category,
+ COUNT(*) AS n,
+ ROUND(AVG(spent_usd)::numeric, 2) AS avg_spend
+FROM ship_intents
+WHERE terminated_at IS NOT NULL
+ AND terminated_at > now() - interval '7 days'
+GROUP BY status, terminal_blocker_category
+ORDER BY n DESC;
+```
+
+### Top-spend sessions
+
+```sql
+SELECT id, owner, repo, pr_number, status, spent_usd, created_at, terminated_at
+FROM ship_intents
+ORDER BY spent_usd DESC
+LIMIT 20;
+```
+
+### Iterations for one intent
+
+```sql
+SELECT iteration_n, kind, verdict_json->>'kind' AS verdict, cost_usd, started_at, finished_at
+FROM ship_iterations
+WHERE intent_id = ''
+ORDER BY iteration_n;
+```
+
+### Fix-attempt heatmap (signatures retried near the cap)
+
+```sql
+SELECT intent_id, signature, attempts, last_seen_at
+FROM ship_fix_attempts
+WHERE attempts >= 2
+ORDER BY attempts DESC, last_seen_at DESC;
+```
+
+## Triage decision tree
+
+```text
+Is status terminal?
+├── Yes — read terminal_blocker_category. Use the table above.
+└── No — status is active or paused.
+ ├── status=paused — stopped by user, awaiting resume or abort.
+ └── status=active — read ship_continuations for this intent.
+ ├── wake_at in the past — tickle scheduler should pick it up next cycle.
+ │ If multiple cycles pass with no progress, check tickle-scheduler logs
+ │ (event:"ship.tickle.due") and ship.iteration.* events.
+ └── wake_at in the future — session is waiting on a check_run / review_comment / synchronize event
+ to fire the reactor. Verify the GitHub App is subscribed to those events.
+```
+
+## When to abort vs let it run
+
+- Wall-clock has not yet hit `deadline_at` and the iteration count is below `MAX_SHIP_ITERATIONS` → let the tickle scheduler run another cycle.
+- Same failure signature has retried `FIX_ATTEMPTS_PER_SIGNATURE_CAP` times → the bot will terminate itself with `flake-cap` on the next check; no manual abort needed.
+- Session is genuinely wrong direction → `bot:abort-ship` and re-scope.
diff --git a/docs/operate/runbooks/triage.md b/docs/operate/runbooks/triage.md
new file mode 100644
index 00000000..c513ff7e
--- /dev/null
+++ b/docs/operate/runbooks/triage.md
@@ -0,0 +1,63 @@
+# Runbook — triage
+
+Triage is a binary `heavy` classifier. It runs on every event (subject to the kill-switch and circuit breaker) and answers one question: should this job prefer an ephemeral daemon? `heavy=true` is one of the two triggers that can spawn an ephemeral daemon Pod (the other is queue overflow).
+
+## What a call returns
+
+```json
+{ "heavy": true, "confidence": 0.92, "rationale": "..." }
+```
+
+There is no `complexity` field and no `maxTurns` mapping — `maxTurns` always comes from `config.defaultMaxTurns` regardless of the triage outcome.
+
+## Confidence threshold
+
+At or above `TRIAGE_CONFIDENCE_THRESHOLD`, `heavy` is accepted as-is. Below it, the router treats the signal as `heavy=false` — the job routes to `persistent-daemon` and the log line carries `triage_fallback_reason=sub-threshold`. Day-one default is `1.0` so only perfectly confident results route an ephemeral spawn.
+
+## Circuit breaker
+
+Triage wraps the LLM call in a circuit breaker (`src/orchestrator/triage.ts`). Consecutive failures trip the breaker; while open, the function short-circuits to `heavy=false` and emits `triage_fallback_reason=circuit-open`. The breaker re-closes after a cooldown.
+
+## Six fallback reasons
+
+All emitted as `triage_fallback_reason` in pino logs.
+
+| Reason | Trigger |
+| --------------- | ------------------------------------------------------------------- |
+| `disabled` | `TRIAGE_ENABLED=false` — short-circuits without calling the LLM. |
+| `circuit-open` | Breaker tripped after consecutive failures. |
+| `timeout` | The call exceeded `TRIAGE_TIMEOUT_MS`. |
+| `llm-error` | The provider returned an error. |
+| `parse-error` | The JSON response failed schema validation. |
+| `sub-threshold` | Parsed successfully but `confidence < TRIAGE_CONFIDENCE_THRESHOLD`. |
+
+## Cost implications
+
+Every event attempts triage. When `TRIAGE_ENABLED=false` or the breaker is open the call short-circuits **before** hitting Haiku, so those paths are free. When the call proceeds, one Haiku invocation is the dominant marginal cost on a busy install.
+
+Mitigations:
+
+- `TRIAGE_CONFIDENCE_THRESHOLD` defaults to `1.0` (strictest). Lower toward `0.8`–`0.9` to accept more heavy verdicts; raising above `1.0` is unsupported and gates out every response. The compute cost is unchanged either way — the knob only controls whether the result routes an ephemeral spawn.
+- Flip `TRIAGE_ENABLED=false` during a provider incident to suppress spend without redeploying.
+- Keep `TRIAGE_MAX_TOKENS` low (the response schema is ~40 tokens).
+
+## Tuning knobs
+
+| Variable | Default | When to change |
+| ----------------------------- | ----------- | ------------------------------------------------------- |
+| `TRIAGE_ENABLED` | `true` | Incident kill-switch. |
+| `TRIAGE_MODEL` | `haiku-3-5` | Experiment with newer Haiku aliases for latency. |
+| `TRIAGE_CONFIDENCE_THRESHOLD` | `1.0` | Relax to `0.8`–`0.9` once the classifier is calibrated. |
+| `TRIAGE_MAX_TOKENS` | `256` | Only raise if rationale is being truncated. |
+| `TRIAGE_TIMEOUT_MS` | `5000` | Raise if provider latency is consistently > 5 s. |
+
+Full schema: [`../configuration.md`](../configuration.md#triage).
+
+## Querying
+
+| Question | Approach |
+| ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
+| Triage accuracy this week | Sample log lines with `heavy:true` or `heavy:false` and grade against the actual job duration / cost. |
+| Spend on triage calls | `triageSpend(days)` helper in `src/db/queries/dispatch-stats.ts`. |
+| Fallback rate | Log query: `triage_fallback_reason:*` grouped by reason. |
+| Sub-threshold tail | Histogram of `confidence` for non-fallback events; if the 90th percentile is below `TRIAGE_CONFIDENCE_THRESHOLD`, lower the threshold. |
diff --git a/docs/operate/setup.md b/docs/operate/setup.md
new file mode 100644
index 00000000..e3451862
--- /dev/null
+++ b/docs/operate/setup.md
@@ -0,0 +1,117 @@
+# Local development setup
+
+This page covers running the bot on your laptop against a real GitHub App. For first-time GitHub App creation, see [`github-app.md`](github-app.md). For production deployment, see [`deployment.md`](deployment.md).
+
+## Prerequisites
+
+| Tool | Version | Purpose |
+| ----------------------- | ------------------------------------------ | -------------------------------------------------------------------------------- |
+| [Bun](https://bun.sh) | from `.tool-versions` (currently `1.3.13`) | Runtime and package manager. |
+| Git | any | Repository checkout during agent execution. |
+| Docker | any recent | Local Postgres + Valkey via `docker-compose.dev.yml`. |
+| GitHub account | — | Admin access to the org or personal account where the App is registered. |
+| Tunnelling tool | — | ngrok or smee.io to expose `localhost:3000` to GitHub. |
+| AI provider credentials | — | One of: Anthropic API key, Claude Code OAuth token, AWS credentials for Bedrock. |
+
+## First run
+
+```bash
+git clone https://github.com/chrisleekr/github-app-playground.git
+cd github-app-playground
+bun install
+
+# Start Postgres + Valkey in the background
+bun run dev:deps
+
+# Copy and fill .env
+cp .env.example .env
+# Edit .env — see configuration.md for every variable.
+
+# Run database migrations
+bun run db:migrate
+
+# Run in watch mode
+bun run dev
+```
+
+The HTTP server binds to `PORT` (default `3000`). Hit `http://localhost:3000/healthz` to confirm it's up; `http://localhost:3000/readyz` confirms the data layer is reachable.
+
+## Expose the local server
+
+GitHub must reach your webhook URL over the internet.
+
+```bash
+# Wrapped script: ngrok on port 3000
+bun run dev:ngrok
+# Copy the https://....ngrok.io URL into the GitHub App's webhook URL field.
+```
+
+Alternative — smee.io:
+
+```bash
+smee --url https://smee.io/ --path /api/github/webhooks --port 3000
+```
+
+The local trigger phrase is conventionally `@chrisleekr-bot-dev` (set `TRIGGER_PHRASE` in `.env`) so the dev installation does not collide with the production bot's mention.
+
+## Common dev commands
+
+```bash
+bun run dev # Watch mode
+bun run start # Production binary (after bun run build)
+bun run build # Compile to dist/
+
+bun run check # Unified gate: typecheck + lint + format + tests + no-destructive
+bun run typecheck # tsc --noEmit
+bun run lint # ESLint
+bun run lint:fix # ESLint auto-fix
+bun run format # Prettier check
+bun run format:fix # Prettier auto-fix
+
+bun test # Run tests via scripts/test-isolated.sh (Bun mock isolation)
+bun run test:fast # Direct bun test (no isolation)
+bun run test:watch # Watch mode
+bun run test:coverage # With coverage report
+
+bun run audit:ci # Severity-gated bun audit (used by CI)
+bun run db:migrate # Run migrations against DATABASE_URL
+bun run dev:deps # Up Postgres + Valkey
+bun run dev:deps:down # Tear down
+bun run dev:daemon # Run a daemon locally against the running orchestrator
+
+bun run docs:install # Install MkDocs Python deps (one-time)
+bun run docs:serve # Live-reload preview at http://localhost:8000
+bun run docs:build # Strict build (CI also runs this)
+```
+
+`bun run check` is the single command to run before opening a PR.
+
+## Running a daemon locally
+
+The webhook server embeds an orchestrator that talks to daemons over WebSocket. To exercise the full pipeline locally:
+
+```bash
+# Terminal 1 — orchestrator (webhook server)
+bun run dev
+
+# Terminal 2 — local daemon
+bun run dev:daemon
+```
+
+The daemon connects back to `ws://localhost:3002` (`WS_PORT`) using `DAEMON_AUTH_TOKEN` from `.env`. From there, every `@chrisleekr-bot-dev` mention exercises the full webhook → orchestrator → daemon → pipeline path.
+
+## Testing webhook delivery
+
+1. Open an issue or PR in a repository where your dev App is installed.
+2. Comment `@chrisleekr-bot-dev triage this`.
+3. The bot creates a tracking comment within ~2 s.
+
+If nothing happens, check:
+
+| Symptom | Likely cause |
+| ------------------------------------ | ------------------------------------------------------------------------------------------------------------ |
+| 401 / 403 in tunnel logs | `GITHUB_WEBHOOK_SECRET` mismatch with the App settings (no trailing newline). |
+| 200 OK but no comment | `ALLOWED_OWNERS` excludes the repo owner; or `MAX_CONCURRENT_REQUESTS` is saturated. |
+| `GITHUB_APP_PRIVATE_KEY` parse error | Set the full PEM including `-----BEGIN…` / `-----END…` lines (literal `\n` is normalised). |
+| `ANTHROPIC_API_KEY is required` | `CLAUDE_PROVIDER` defaults to `anthropic`; set the key or switch to `bedrock`. |
+| Bot mention ignored locally | `TRIGGER_PHRASE` is unchanged from the default `@chrisleekr-bot`; the dev App expects `@chrisleekr-bot-dev`. |
diff --git a/docs/use/invoking.md b/docs/use/invoking.md
new file mode 100644
index 00000000..0214efa1
--- /dev/null
+++ b/docs/use/invoking.md
@@ -0,0 +1,90 @@
+# Invoking the bot
+
+The bot reacts to three kinds of input: mentions in a comment, labels applied to an issue or PR, and (for `bot:ship` only) natural-language asks that include the trigger phrase. All three converge on the same workflow registry — only the **surface** differs in logs.
+
+## The three surfaces
+
+| Surface | Where you put it | Example |
+| ------------------- | -------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
+| **Mention + verb** | Issue or PR comment | `@chrisleekr-bot triage this` · `@chrisleekr-bot ship this please` |
+| **Literal command** | PR comment | `bot:ship` · `bot:ship --deadline 2h` · `bot:abort-ship` |
+| **Label** | Apply to issue or PR | `bot:triage`, `bot:plan`, `bot:implement`, `bot:review`, `bot:resolve`, `bot:ship`, `bot:stop`, `bot:resume`, `bot:abort-ship` |
+
+The trigger phrase that gates mentions is `@chrisleekr-bot` by default and can be overridden with `TRIGGER_PHRASE` (typically `@chrisleekr-bot-dev` for local development).
+
+## How a comment reaches a workflow
+
+```mermaid
+flowchart TD
+ Cmt["Comment with @chrisleekr-bot or bot:verb"]:::input
+ Verify["Webhook verified
HMAC-SHA256"]:::guard
+ Idem["Idempotency check
delivery id + tracking comment"]:::guard
+ Allow["ALLOWED_OWNERS allowlist"]:::guard
+ Router{{"Trigger router"}}:::fork
+ Lit["Literal regex
bot:verb"]:::route
+ NL["Mention + NL classifier
Bedrock single-turn"]:::route
+ LabelEvt["issues.labeled or
pull_request.labeled"]:::input
+ LabelMatch["registry.getByLabel"]:::route
+ Enqueue["enqueueJob
Valkey queue:jobs"]:::store
+ Daemon["Daemon claims offer"]:::work
+ Pipe["src/core/pipeline.ts"]:::work
+ Track["Tracking comment finalised"]:::done
+
+ Cmt --> Verify --> Idem --> Allow --> Router
+ Router --> Lit
+ Router --> NL
+ LabelEvt --> Allow
+ Allow -. label path .-> LabelMatch
+ Lit --> Enqueue
+ NL --> Enqueue
+ LabelMatch --> Enqueue
+ Enqueue --> Daemon --> Pipe --> Track
+
+ classDef input fill:#0b5cad,stroke:#083e74,color:#ffffff
+ classDef guard fill:#164a3a,stroke:#0d2c24,color:#ffffff
+ classDef fork fill:#6a2080,stroke:#451454,color:#ffffff
+ classDef route fill:#8a5a00,stroke:#5c3d00,color:#ffffff
+ classDef store fill:#5c3d00,stroke:#3d2900,color:#ffffff
+ classDef work fill:#114a82,stroke:#0a2f56,color:#ffffff
+ classDef done fill:#2a6f2a,stroke:#1a4d1a,color:#ffffff
+```
+
+## Idempotency
+
+A duplicate webhook delivery never spawns a duplicate job. The router checks two layers:
+
+1. **Fast in-memory** — a `Map` keyed by the `X-GitHub-Delivery` header, swept every 60 minutes (`src/webhook/router.ts`). Lost on restart.
+2. **Durable** — `isAlreadyProcessed` looks for the hidden delivery marker that the bot embeds in its tracking comment. Survives pod restarts, OOM kills, and crash loops; works without `DATABASE_URL`.
+
+If both miss, the request proceeds to the allowlist + concurrency guard.
+
+## What you see while it runs
+
+Comment-driven runs stack four reactions on your trigger comment so the lifecycle is visible at a glance:
+
+| Stage | Reaction |
+| -------------------------- | -------- |
+| Trigger detected | 👀 |
+| Job dispatched to a daemon | 🚀 |
+| Workflow succeeded | 🎉 |
+| Workflow failed | 😕 |
+
+Reactions are additive — the combined set is the audit trail. Label-driven runs skip reactions because there is no comment to react on.
+
+The bot also writes a single **tracking comment** per run. For workflows that take minutes (triage, plan, implement, review, resolve, ship), the comment opens with a "Working…" body and is rewritten in place at major checkpoints and at the terminal state. You only need to watch one comment.
+
+## What gets refused
+
+| Refusal | Cause |
+| ------------------------ | -------------------------------------------------------------------------------------------------------------------- |
+| Silent skip | Repository owner is not in `ALLOWED_OWNERS`. No comment is posted. |
+| "Capacity reached" reply | More than `MAX_CONCURRENT_REQUESTS` agent runs are already in flight. Re-invoke later. |
+| Clarification reply | Mention-driven request whose intent classifier confidence fell below `INTENT_CONFIDENCE_THRESHOLD` (default `0.75`). |
+| "Unsupported" reply | Mention-driven request whose intent does not map to any registered workflow. |
+| `bot:ship` refusal | Target branch is in `SHIP_FORBIDDEN_TARGET_BRANCHES`, or the PR head is closed / on a fork without push access. |
+
+See [`use/safety.md`](safety.md) for what the bot will and will not do once a job is accepted.
+
+## Catalog
+
+A complete table of `bot:*` commands lives at [`use/workflows/`](workflows/index.md). The headline shipping workflow is documented at [`use/workflows/ship.md`](workflows/ship.md).
diff --git a/docs/use/safety.md b/docs/use/safety.md
new file mode 100644
index 00000000..0b8439a5
--- /dev/null
+++ b/docs/use/safety.md
@@ -0,0 +1,49 @@
+# What the bot will and won't do
+
+This page enumerates the boundary the bot enforces on itself. The boundary is defended in two places: handler code and a static guard at `scripts/check-no-destructive-actions.ts` that runs in `bun run check`.
+
+## Static guard — destructive actions
+
+`scripts/check-no-destructive-actions.ts` scans `src/workflows/ship/` (recursive) and the four scoped daemon executors for the following patterns. The CI gate fails the build on any match outside of comments.
+
+| Pattern | Why blocked |
+| ---------------------------------------------------------------------- | --------------------------------------------------------------- |
+| `git push --force` / `git push -f` | Always replaced with `--force-with-lease` after a clean rebase. |
+| `git reset --hard` | The bot never discards local work without explicit intent. |
+| `git branch -D` / `git push --delete` | Branch deletion is a human action. |
+| `git filter-branch` / `git filter-repo` | History rewriting is out of scope. |
+| `gh pr merge` / `mergePullRequest` (GraphQL) / `mergeBranch` (GraphQL) | The bot never merges. |
+
+`src/workflows/handlers/resolve.ts` documents a non-negotiable requirement that `octokit.rest.pulls.merge` must never be called (header comment + a step in the agent's instruction set). It is a documented constraint, not a runtime guard — the static guard above is what fails the build if anyone tries.
+
+## Pause / resume / abort (ship sessions)
+
+| Verb | Behaviour |
+| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
+| `bot:stop` | Sets `ship_intents.status = 'paused'`. The deadline keeps counting down. The session can be resumed. |
+| `bot:resume` | Verifies no foreign push has landed since the pause, clears the Valkey cancel flag, re-enqueues the continuation. Refused if a manual push happened during the pause. |
+| `bot:abort-ship` | Sets the Valkey cancel flag at `ship:cancel:{intent_id}`, waits ≤2 s for the next cooperative checkpoint, force-transitions to `aborted_by_user`. After abort, the bot performs zero further mutating actions on the PR. |
+
+## Foreign-push semantics
+
+The shepherding probe detects when a non-bot principal has pushed to the PR's head branch. The session terminates immediately with `human_took_over` + `terminal_blocker_category='manual-push-detected'`. The bot does not race the human or revert the push.
+
+## Forbidden target branches
+
+`SHIP_FORBIDDEN_TARGET_BRANCHES` (comma-separated) lists branches `bot:ship` will refuse to shepherd into. Typical values: `main`, `production`, `release`. The refusal is delivered as a maintainer-facing reply naming the offending branch; no `ship_intents` row is created.
+
+## Reviews
+
+`bot:review` posts findings as **inline comments**, one MCP call per finding. It never:
+
+- Submits a top-level `APPROVE` or `REQUEST_CHANGES` review (those are human prerogatives).
+- Merges the PR.
+- Bundles findings into a single wall-of-text review POST.
+
+## Idempotency
+
+A duplicate webhook delivery — same `X-GitHub-Delivery` header or same tracking-comment marker — is dropped before any work runs. The fast in-memory `Map` is lost on restart; the durable check (looking for the bot's hidden delivery marker in existing tracking comments) survives crash loops.
+
+## Fork PRs
+
+The bot's installation token cannot push to a fork branch. PR-side workflows (`review`, `resolve`, `ship`) detect this, post a top-level comment asking the contributor to rebase, and proceed against the stale head — flagging affected findings in the final report.
diff --git a/docs/use/workflows/implement.md b/docs/use/workflows/implement.md
new file mode 100644
index 00000000..cc11bd0c
--- /dev/null
+++ b/docs/use/workflows/implement.md
@@ -0,0 +1,43 @@
+# `bot:implement`
+
+Opens a PR with code, tests, and a filled-out PR template based on the prior plan.
+
+| Field | Value |
+| --------------- | ------------------------------------- |
+| Label | `bot:implement` |
+| Mention | `@chrisleekr-bot implement this` |
+| Accepted target | Issue |
+| Requires prior | A successful `plan` run |
+| Artifact | `IMPLEMENT.md` |
+| Side effects | New branch, new commits, new PR |
+| Source | `src/workflows/handlers/implement.ts` |
+
+## Inputs
+
+- Issue body.
+- The plan markdown from the prior `plan` run.
+- A fresh shallow clone of the repository.
+
+## Outputs
+
+| Field | Type | Notes |
+| ------------------------------------------------- | -------- | --------------------------------------------------------------------------------------------------------------------- |
+| `state.pr_number`, `state.pr_url`, `state.branch` | strings | The PR the bot opened. |
+| `state.report` | markdown | Full `IMPLEMENT.md` (Summary / Files changed / Commits / Tests run / Verification). Embedded in the tracking comment. |
+| `state.costUsd`, `state.turns` | metrics | — |
+
+## PR detection
+
+`findRecentOpenedPr` filters on `pr.user?.type === 'Bot'` plus `created_at >= since - 5s`. It deliberately does not match on a hard-coded login slug — dev installs publish as `chrisleekr-bot-dev[bot]` and prod as `chrisleekr-bot[bot]`, so a slug check would produce false negatives.
+
+## PR body
+
+The agent reads `.github/PULL_REQUEST_TEMPLATE/bot-implement.md` and fills every section based on actual work, then passes it via `gh pr create --body-file …`. This keeps bot PRs structurally consistent and prevents `gh` from auto-falling back to the human PR template.
+
+## Stop conditions
+
+- Pipeline pushes a branch and opens a PR.
+- Pipeline succeeds but `findRecentOpenedPr` returns null → handler fails with `"implement completed but no PR was found"`.
+- Pipeline fails → handler reports the underlying error.
+
+The handler does **not** poll CI or reviewer state — that is `resolve`'s job, after `review` has run.
diff --git a/docs/use/workflows/index.md b/docs/use/workflows/index.md
new file mode 100644
index 00000000..fd7059be
--- /dev/null
+++ b/docs/use/workflows/index.md
@@ -0,0 +1,35 @@
+# Workflows
+
+Six workflows are registered today (`src/workflows/registry.ts`). Each has a single label, a single comment-mention verb, and produces one Markdown artifact that becomes the body of the tracking comment.
+
+| Workflow | Label | Surfaces | What it does | Detail |
+| --------------------------- | --------------- | ---------------------------------------------- | ---------------------------------------------------------------------------- | ---------------- |
+| [`triage`](triage.md) | `bot:triage` | Issue label or comment | Decides whether an issue is actionable, with structural or runtime evidence | `TRIAGE.md` |
+| [`plan`](plan.md) | `bot:plan` | Issue label or comment, after `triage` | Writes an implementation plan | `PLAN.md` |
+| [`implement`](implement.md) | `bot:implement` | Issue label or comment, after `plan` | Opens a PR with code, tests, and a filled-out PR template | `IMPLEMENT.md` |
+| [`review`](review.md) | `bot:review` | PR label or comment | Reads the diff in full, posts findings as inline comments | `REVIEW.md` |
+| [`resolve`](resolve.md) | `bot:resolve` | PR label or comment | Fixes failing CI, replies to review threads, pushes new commits | `RESOLVE.md` |
+| [`ship`](ship.md) | `bot:ship` | PR comment, label, or natural-language mention | Shepherds an open PR to merge-ready: probe → fix → reply → wait, until clean | tracking comment |
+
+## How they relate
+
+`triage`, `plan`, and `implement` are the issue-side cascade for new work. `review` and `resolve` are the PR-side pair: `review` proactively reads a diff and posts findings; `resolve` reactively answers existing feedback and fixes failing CI. The split is deliberate — conflating "look at this PR" with "fix this PR" was the design mistake the verb-rename corrected.
+
+`ship` is the PR shepherding lifecycle (its own state machine, its own database tables). It does not run the cascade above; it drives an open PR through the merge-readiness probe ladder until a human can hit merge. See [`ship.md`](ship.md).
+
+## Common rules across all workflows
+
+- **The bot never merges.** No workflow calls `pulls.merge` or posts an `APPROVE` / `REQUEST_CHANGES` review. Static guard at `scripts/check-no-destructive-actions.ts`.
+- **Always-rebase semantics.** PR-side workflows (`review`, `resolve`, `ship`) rebase the branch onto base before reading the diff if it is behind, then `git push --force-with-lease`. Fork PRs cannot be force-pushed by the bot — it asks the contributor to rebase and proceeds against the stale head.
+- **One Markdown artifact, one tracking comment.** Each run captures `.md` from the working tree before cleanup and embeds it verbatim in the tracking comment.
+- **Cost is visible.** Every workflow records `cost_usd`, `turns`, and `wall_clock_ms` on the run row. The shepherding lifecycle exposes cumulative spend in the tracking comment header.
+
+## Trigger-comment intent classifier
+
+A comment that mentions the trigger phrase is routed through `src/workflows/intent-classifier.ts` — a single-turn Haiku call that returns `{ workflow, confidence, rationale }`.
+
+- `confidence < INTENT_CONFIDENCE_THRESHOLD` (default `0.75`) → the dispatcher posts a clarification reply and stops.
+- `workflow` not in registry → refusal reply.
+- `workflow` in registry → same dispatch as the label path.
+
+The classifier prompt distinguishes `review` (proactive — find bugs, post inline findings) from `resolve` (reactive — fix CI, answer feedback). Tune the threshold per environment with `INTENT_CONFIDENCE_THRESHOLD`.
diff --git a/docs/use/workflows/plan.md b/docs/use/workflows/plan.md
new file mode 100644
index 00000000..374a78f0
--- /dev/null
+++ b/docs/use/workflows/plan.md
@@ -0,0 +1,34 @@
+# `bot:plan`
+
+Writes an implementation plan for an issue that has already passed triage.
+
+| Field | Value |
+| --------------- | --------------------------------------------------------------------- |
+| Label | `bot:plan` |
+| Mention | `@chrisleekr-bot plan this out` |
+| Accepted target | Issue |
+| Requires prior | A successful `triage` run on the same issue with `state.valid = true` |
+| Artifact | `PLAN.md` |
+| Side effects | None |
+| Source | `src/workflows/handlers/plan.ts` |
+
+## Inputs
+
+- Issue body.
+- The triage state from the prior run (verdict, evidence, recommended next).
+- A fresh shallow clone of the repository.
+
+## Outputs
+
+| Field | Type | Notes |
+| -------------------------------------------------- | -------- | -------------------------------------------------------------------------------------------------- |
+| `state.plan` | markdown | Full `PLAN.md` body, captured before workspace cleanup. Embedded verbatim in the tracking comment. |
+| `state.costUsd`, `state.turns`, `state.durationMs` | metrics | — |
+
+## Stop conditions
+
+The agent writes `PLAN.md`; the pipeline reports success or failure. No turn cap — the agent runs to completion.
+
+## Re-trigger semantics
+
+`plan` is **fresh** when a successful `plan` row exists for the issue created **after** the most recent successful `triage`. Re-applying the label when `plan` is stale enqueues a fresh run; an in-flight stale run is not interrupted, so wait for it to terminate before re-applying.
diff --git a/docs/use/workflows/resolve.md b/docs/use/workflows/resolve.md
new file mode 100644
index 00000000..da780b88
--- /dev/null
+++ b/docs/use/workflows/resolve.md
@@ -0,0 +1,41 @@
+# `bot:resolve`
+
+Fixes failing CI, replies to existing review threads, and pushes new commits.
+
+| Field | Value |
+| --------------- | --------------------------------------------------------------------------------------------------------------------------------- |
+| Label | `bot:resolve` |
+| Mention | `@chrisleekr-bot fix the CI failures` · `@chrisleekr-bot address the review comments` · `@chrisleekr-bot respond to the feedback` |
+| Accepted target | Pull request |
+| Requires prior | — |
+| Artifact | `RESOLVE.md` |
+| Side effects | New commits on the PR head branch; replies to review threads; force-push of a clean rebase if branch is behind base |
+| Source | `src/workflows/handlers/resolve.ts` |
+
+## Method
+
+For each open reviewer comment the agent classifies as **Valid**, **Partially Valid**, **Invalid**, or **Needs Clarification**, then:
+
+- Fixes valid ones with new commits.
+- Replies to all four classes appropriately.
+- Fixes failing CI when there is a clear root cause.
+
+Branch refresh happens first if the head is stale (same logic as `review`).
+
+Reviewer-thread replies are posted via `gh api repos///pulls//comments//replies -X POST`. The bot's `gh` and `git` calls authenticate via `GH_TOKEN` and `GITHUB_TOKEN`, injected from the GitHub App installation token by `buildProviderEnv` in `src/core/executor.ts`.
+
+## Outputs
+
+| Field | Type | Notes |
+| ------------------------------ | ---------- | ----------------------------------------------------------------------------------------- |
+| `state.failing_checks` | `string[]` | Names of failing checks at start of run. |
+| `state.top_level_comments` | number | Count of open top-level review comments. |
+| `state.branch_state` | object | Pre-refresh snapshot. |
+| `state.report` | markdown | Full `RESOLVE.md` (Summary / CI status / Review comments / Commits pushed / Outstanding). |
+| `state.costUsd`, `state.turns` | metrics | — |
+
+## Stop conditions
+
+- `FIX_ATTEMPTS_CAP = 3` — maximum consecutive CI-fix attempts per run.
+- `POLL_WAIT_SECS_CAP = 900` (15 min) — reviewer-patience window before the run terminates.
+- The handler **never** calls `octokit.rest.pulls.merge` — merging is a human action.
diff --git a/docs/use/workflows/review.md b/docs/use/workflows/review.md
new file mode 100644
index 00000000..04a05643
--- /dev/null
+++ b/docs/use/workflows/review.md
@@ -0,0 +1,56 @@
+# `bot:review`
+
+Reads a PR diff in full, cross-references with the rest of the codebase, and posts findings as inline comments.
+
+| Field | Value |
+| --------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
+| Label | `bot:review` |
+| Mention | `@chrisleekr-bot review this PR` · `@chrisleekr-bot do a code review` · `@chrisleekr-bot check for issues` |
+| Accepted target | Pull request |
+| Requires prior | — |
+| Artifact | `REVIEW.md` |
+| Side effects | Inline review comments via `mcp__github_inline_comment__create_inline_comment`; force-push of a clean rebase if branch is behind base |
+| Source | `src/workflows/handlers/review.ts` |
+
+## Method
+
+The agent operates as a senior engineer:
+
+- Reads every changed file in full, not just the diff window.
+- Cross-references callers, tests, and related code.
+- Runs `bun test`, `bun run typecheck`, `bun run lint` when uncertain.
+- Only posts findings it can defend with evidence.
+
+Each finding carries a severity prefix:
+
+| Severity | Meaning |
+| ----------- | --------------------------------------------------- |
+| `[blocker]` | Must fix before merge — correctness or security. |
+| `[major]` | Should fix before merge — likely bug, missing test. |
+| `[minor]` | Nice to fix — readability. |
+| `[nit]` | Taste, optional. Not counted in `findings.total`. |
+
+Findings are posted **one MCP call per finding**, never as a single bundled review. This guarantees each finding lands on the right line with its own resolvable thread.
+
+## No-findings case
+
+The agent must still post a top-level review body listing exactly what was checked (files read, classes of issue scanned, tests run) and why no issues were flagged. Silence is indistinguishable from "didn't actually look".
+
+## Branch refresh
+
+If the PR head is behind base **and** the branch is not on a fork, the agent rebases onto base, resolves conflicts honestly (reads the surrounding code, runs typecheck and tests, never blindly takes ours/theirs), and force-pushes with `--force-with-lease`. Fork PRs get a comment asking the contributor to rebase, then the review proceeds against the stale head with affected findings flagged.
+
+## Outputs
+
+| Field | Type | Notes |
+| ----------------------------------------------------------- | ------------------------------------------------------- | ----------------------------------------------------------- |
+| `state.head_sha` | string | The SHA the review ran against (post-rebase if applicable). |
+| `state.changed_files`, `state.additions`, `state.deletions` | numbers | Diff stats. |
+| `state.branch_state` | `{commits_behind_base, commits_ahead_of_base, is_fork}` | Pre-refresh snapshot. |
+| `state.findings` | `{blocker, major, minor, nit, total}` | Counted from the severity tags; `total` excludes `nit`. |
+| `state.report` | markdown | Full `REVIEW.md`. |
+| `state.costUsd`, `state.turns` | metrics | — |
+
+## Push policy
+
+The only push acceptable from `review` is `git push --force-with-lease` after a clean rebase onto base (same diff, fresh head SHA). The handler never creates code commits, never calls `pulls.merge`, never posts an `APPROVE` or `REQUEST_CHANGES` review.
diff --git a/docs/use/workflows/ship.md b/docs/use/workflows/ship.md
new file mode 100644
index 00000000..94bff3b3
--- /dev/null
+++ b/docs/use/workflows/ship.md
@@ -0,0 +1,125 @@
+# `bot:ship` — PR shepherding to merge-ready
+
+The shepherding lifecycle takes an open pull request from "needs work" to "ready for human merge". The bot drives the probe → fix → reply → wait loop until the merge-readiness probe says the PR is clean. **The bot never merges**; the final action is always a human's.
+
+The lifecycle lives in `src/workflows/ship/` (entry point `runShipFromCommand` in `session-runner.ts`). Each session is a row in `ship_intents`, with iteration history in `ship_iterations` and wake state in `ship_continuations`.
+
+## How to invoke
+
+| Surface | Example | Notes |
+| ----------- | ---------------------------------------------------- | --------------------------------------------------------------------------------------------- |
+| **Literal** | `bot:ship` · `bot:ship --deadline 2h` | PR comment. Deterministic regex; never costs an LLM call. |
+| **Natural** | `@chrisleekr-bot ship this please` | Requires the trigger-phrase mention. Without the mention the comment is skipped at zero cost. |
+| **Label** | Apply `bot:ship` (or `bot:ship/deadline=2h`) to a PR | The bot self-removes the label after acting. Re-applying re-triggers. |
+
+The four lifecycle verbs are `ship`, `stop`, `resume`, `abort-ship`. All four are available on all three surfaces.
+
+`--deadline` accepts `Nh` / `Nm` / `Ns`. The session deadline is clamped to `MAX_WALL_CLOCK_PER_SHIP_RUN` (default 4h).
+
+## How to monitor
+
+Each session writes a single canonical tracking comment marked with ``. The body shows current phase, last action, next queued action, iteration count, USD spent, deadline, and (on terminal) the blocker category. One comment is enough to know exactly where the bot is.
+
+## How to pause, resume, abort
+
+| Verb | Effect | Recoverable? |
+| ---------------- | ------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------- |
+| `bot:stop` | Sets `ship_intents.status = 'paused'`. Deadline keeps counting down. | Yes — `bot:resume`. |
+| `bot:resume` | Verifies no foreign push since the pause, clears the cancel flag, re-enqueues the continuation. | — |
+| `bot:abort-ship` | Sets the Valkey cancel flag, waits ≤2 s for a cooperative checkpoint, then force-transitions to `aborted_by_user`. | No. After abort, the bot performs zero further mutating actions on the PR. |
+
+## What runs each iteration
+
+```mermaid
+flowchart LR
+ Iter["Iteration N starts"]:::start
+ Probe["Probe
GraphQL PR snapshot"]:::work
+ Verdict{{"Verdict"}}:::fork
+ Behind["Refresh branch
git rebase --force-with-lease"]:::fix
+ Failing["Resolve failing checks"]:::fix
+ Pending["Wait for pending checks
tickle on check_run.completed"]:::wait
+ Threads["Reply to open review threads
resolve thread on success"]:::fix
+ ChangesReq["Wait for human action
changes_requested"]:::wait
+ Ready["Terminal:ready
tracking comment + status flip"]:::done
+ Took["Terminal:human_took_over
foreign push detected"]:::halt
+
+ Iter --> Probe --> Verdict
+ Verdict -->|behind base| Behind --> Iter
+ Verdict -->|failing checks| Failing --> Iter
+ Verdict -->|pending checks| Pending --> Iter
+ Verdict -->|open threads| Threads --> Iter
+ Verdict -->|changes requested| ChangesReq --> Iter
+ Verdict -->|ready| Ready
+ Probe -. detects manual push .-> Took
+
+ classDef start fill:#0b5cad,stroke:#083e74,color:#ffffff
+ classDef work fill:#164a3a,stroke:#0d2c24,color:#ffffff
+ classDef fork fill:#6a2080,stroke:#451454,color:#ffffff
+ classDef fix fill:#8a5a00,stroke:#5c3d00,color:#ffffff
+ classDef wait fill:#5c3d00,stroke:#3d2900,color:#ffffff
+ classDef done fill:#2a6f2a,stroke:#1a4d1a,color:#ffffff
+ classDef halt fill:#852020,stroke:#5a1414,color:#ffffff
+```
+
+The verdict ladder is ordered: `human_took_over` > `behind_base` > `failing_checks` > `pending_checks` > `mergeable_pending` > `changes_requested` > `open_threads` > `ready`. The first matching rung wins — fixing failing checks always precedes replying to threads, and a manual push always wins outright.
+
+`mergeable=null` is treated specially: the probe backs off through `MERGEABLE_NULL_BACKOFF_MS_LIST` (default `500,1500,4500`); exhausting the list yields a `mergeable_pending` verdict and the session yields rather than spinning.
+
+## Status values
+
+```mermaid
+stateDiagram-v2
+ [*] --> active : runShipFromCommand
+ active --> paused : bot:stop
+ paused --> active : bot:resume
+ active --> ready_awaiting_human_merge : verdict=ready
+ active --> human_took_over : foreign push, iteration cap, or flake cap
+ active --> deadline_exceeded : MAX_WALL_CLOCK_PER_SHIP_RUN
+ active --> merged_externally : pull_request.closed merged
+ active --> pr_closed : pull_request.closed not merged
+ active --> aborted_by_user : bot:abort-ship
+ paused --> aborted_by_user : bot:abort-ship
+ paused --> deadline_exceeded : deadline elapsed while paused
+ ready_awaiting_human_merge --> [*]
+ human_took_over --> [*]
+ deadline_exceeded --> [*]
+ merged_externally --> [*]
+ pr_closed --> [*]
+ aborted_by_user --> [*]
+```
+
+## What the bot will and won't do
+
+| Will | Won't |
+| ------------------------------------------------------------------- | ---------------------------------------------------------------- |
+| Force-push with `--force-with-lease` after a clean rebase onto base | Force-push without rebasing |
+| Push fix commits in response to failing CI | Merge the PR (`gh pr merge` is statically guarded) |
+| Reply to review threads with the `resolve-review-thread` MCP | Post `APPROVE` or `REQUEST_CHANGES` reviews |
+| Mark a draft PR ready-for-review on terminal `ready` | Cancel a foreign push — manual push wins; the session terminates |
+| Self-remove the `bot:ship` label after acting | Take any mutating action after `bot:abort-ship` |
+
+If the target branch matches `SHIP_FORBIDDEN_TARGET_BRANCHES` (e.g. `main,production`), the trigger is refused before any session is created.
+
+## Re-triggering
+
+Re-applying the `bot:ship` label or re-commenting `bot:ship` on the same PR while a session is **active** is a no-op. Re-applying after the session is **terminal** starts a fresh session — the prior `ship_intents` row is preserved for audit.
+
+## Tuning knobs
+
+Configured at the process level via [`operate/configuration.md`](../../operate/configuration.md#ship). The two you most often touch:
+
+| Variable | Default | Effect |
+| ----------------------------- | ------- | -------------------------------------------------------------------------------------------------------- |
+| `MAX_WALL_CLOCK_PER_SHIP_RUN` | `4h` | Hard ceiling on a session's wall-clock budget. Per-invocation `--deadline` is clamped to this value. |
+| `MAX_SHIP_ITERATIONS` | `50` | Iteration cap. Firing transitions to `human_took_over` with `terminal_blocker_category='iteration-cap'`. |
+
+## When a human should step in
+
+The tracking comment puts the answer at the top: any terminal status other than `ready_awaiting_human_merge` and `merged_externally` means human attention is needed. `terminal_blocker_category` names which class:
+
+- `flake-cap` — the same failure signature was retried `FIX_ATTEMPTS_PER_SIGNATURE_CAP` times (default 3); investigate the flake.
+- `iteration-cap` — the session ran `MAX_SHIP_ITERATIONS` rounds without resolving; re-scope the work.
+- `manual-push-detected` — someone pushed to the PR; the bot stepped back. Re-trigger `bot:ship` if you want the bot to take it from here.
+- `merge-conflict-needs-human` — the rebase produced conflicts the bot would not resolve confidently.
+
+For Day-2 SQL and the other terminal categories, see [`operate/runbooks/stuck-ship-intent.md`](../../operate/runbooks/stuck-ship-intent.md).
diff --git a/docs/use/workflows/triage.md b/docs/use/workflows/triage.md
new file mode 100644
index 00000000..27c9291e
--- /dev/null
+++ b/docs/use/workflows/triage.md
@@ -0,0 +1,48 @@
+# `bot:triage`
+
+Decides whether an issue is actionable. For bug-class issues, the agent must establish either a reproduction, a structural defect (with `file:line` citations), or an invariant test before declaring the bug valid.
+
+| Field | Value |
+| --------------- | ----------------------------------- |
+| Label | `bot:triage` |
+| Mention | `@chrisleekr-bot triage this` |
+| Accepted target | Issue |
+| Requires prior | — |
+| Artifact | `TRIAGE.md` + `TRIAGE_VERDICT.json` |
+| Side effects | None |
+| Source | `src/workflows/handlers/triage.ts` |
+
+## Inputs
+
+- Issue title and body.
+- A fresh shallow clone of the repository (`Read`, `Grep`, `Glob`, `Bash`, `Write` available to the agent).
+
+## Method
+
+The agent classifies the issue (bug, feature, refactor, docs, unclear). For bugs it walks the harness ladder — unit → mocked unit → integration with `bun run dev:deps` Postgres+Valkey → multi-process docker-compose — and names the highest rung tried. Three evidence paths are accepted:
+
+1. **Code inspection** — `file:line` citations for a structural defect (module-scoped state, missing constraint, race window across an `await`, unguarded shared resource).
+2. **Runtime test** — a command that exercises the claim (`bun test`, `bun run typecheck`, a CLI invocation, a `/tmp` scratch script).
+3. **Invariant test** — pins down the property the fix will rely on (e.g. "N concurrent callers → exactly 1 succeeds"). Preferred over synthetic race repros because it survives the fix as a regression guard.
+
+"Race condition we can't trigger" alone is not a valid escape hatch.
+
+## Outputs
+
+| Field | Type | Notes |
+| ----------------------- | ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
+| `state.valid` | boolean | Verdict. |
+| `state.confidence` | float `[0, 1]` | Agent's self-assessment. |
+| `state.summary` | string | Verdict rationale; uncapped. Embedded into the failed-cascade reason when `valid = false`. |
+| `state.recommendedNext` | `'plan'` \| `'stop'` | — |
+| `state.evidence` | `Array<{file, line?, note?}>` | — |
+| `state.reproduction` | `{attempted, reproduced, details}` | `attempted=false` for non-bug class. `reproduced=null` is allowed only after the harness ladder is walked AND an invariant test is ruled out. |
+| `state.report` | markdown | The full `TRIAGE.md`. Embedded verbatim in the tracking comment. |
+
+## Stop conditions
+
+- Agent writes both `TRIAGE.md` and `TRIAGE_VERDICT.json`; the JSON validates against the Zod schema.
+- `valid = false` → handler returns `failed` and any composite cascade halts here.
+- Missing markdown, malformed JSON, or an SDK error → `failed` with a specific reason.
+
+There is no turn cap on triage — the agent runs until the verdict is honestly defensible.
diff --git a/mkdocs.yml b/mkdocs.yml
index cf5ad0d3..3077ddd1 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -92,16 +92,30 @@ markdown_extensions:
nav:
- Home: index.md
- - Setup: SETUP.md
- - Architecture: ARCHITECTURE.md
- - Configuration: CONFIGURATION.md
- - Bot workflows: BOT-WORKFLOWS.md
- - PR shepherding (`bot:ship`): SHIP.md
- - Operator guides:
- - Observability: OBSERVABILITY.md
- - Triage: TRIAGE.md
- - Daemon mode: DAEMON.md
- - Deployment: DEPLOYMENT.md
- - Extending: EXTENDING.md
- - Contributing: CONTRIBUTING.md
- - Changelog: CHANGELOG.md
+ - Use the bot:
+ - Invoking: use/invoking.md
+ - Workflows:
+ - Catalog: use/workflows/index.md
+ - bot:ship: use/workflows/ship.md
+ - bot:triage: use/workflows/triage.md
+ - bot:plan: use/workflows/plan.md
+ - bot:implement: use/workflows/implement.md
+ - bot:review: use/workflows/review.md
+ - bot:resolve: use/workflows/resolve.md
+ - Safety: use/safety.md
+ - Run the service:
+ - Local development: operate/setup.md
+ - GitHub App creation: operate/github-app.md
+ - Deployment: operate/deployment.md
+ - Configuration: operate/configuration.md
+ - Observability: operate/observability.md
+ - Runbooks:
+ - Daemon fleet: operate/runbooks/daemon-fleet.md
+ - Triage: operate/runbooks/triage.md
+ - Stuck bot:ship session: operate/runbooks/stuck-ship-intent.md
+ - Build on it:
+ - Architecture: build/architecture.md
+ - Extending: build/extending.md
+ - Conventions: build/conventions.md
+ - Contributing: build/contributing.md
+ - Changelog: changelog.md
diff --git a/scripts/check-docs-sync.ts b/scripts/check-docs-sync.ts
index 79d9c5ed..45aceab9 100644
--- a/scripts/check-docs-sync.ts
+++ b/scripts/check-docs-sync.ts
@@ -1,18 +1,19 @@
#!/usr/bin/env bun
/**
* Fails CI when a PR touches `src/workflows/**` without also updating
- * `docs/BOT-WORKFLOWS.md` (FR-019 / SC-007 doc-sync guard).
+ * any page under `docs/use/workflows/`.
*
- * Reads the diff between `BASE_SHA..HEAD_SHA` (env vars set by CI) and
- * compares the two path sets. Tests and markdown files under
- * `src/workflows/` are exempt.
+ * Reads the diff between `BASE_SHA...HEAD_SHA` (env vars set by CI;
+ * three-dot range, so commits unique to the PR head relative to the
+ * merge base) and compares the two path sets. Tests and markdown
+ * files under `src/workflows/` are exempt.
*/
import { spawnSync } from "node:child_process";
import { exit } from "node:process";
const WORKFLOW_PATH = /^src\/workflows\//;
const WORKFLOW_EXEMPT = /^src\/workflows\/.*\.(test\.ts|md)$/;
-const DOC_PATH = /^docs\/BOT-WORKFLOWS\.md$/;
+const DOC_PATH = /^docs\/use\/workflows\/.*\.md$/;
function diffFiles(base: string, head: string): string[] {
const res = spawnSync("git", ["diff", "--name-only", `${base}...${head}`], {
@@ -36,15 +37,15 @@ const touchedDoc = files.some((f) => DOC_PATH.test(f));
if (touchedWorkflows.length > 0 && !touchedDoc) {
console.error(
[
- "❌ Doc-sync check failed (FR-019).",
+ "❌ Doc-sync check failed.",
"",
"The following src/workflows/ files changed without a matching",
- "docs/BOT-WORKFLOWS.md update:",
+ "update under docs/use/workflows/:",
...touchedWorkflows.map((f) => ` - ${f}`),
"",
- "Update docs/BOT-WORKFLOWS.md in this PR, or mark the change as",
- "test/docs-only by moving it under src/workflows/**/*.test.ts or",
- "src/workflows/**/*.md.",
+ "Update the relevant docs/use/workflows/*.md page in this PR, or",
+ "mark the change as test/docs-only by moving it under",
+ "src/workflows/**/*.test.ts or src/workflows/**/*.md.",
].join("\n"),
);
exit(1);
diff --git a/src/config.ts b/src/config.ts
index 82815a6f..64b8189a 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -492,21 +492,6 @@ const configSchema = z
// those target branches; the maintainer-facing rejection message
// surfaces the offending branch name.
shipForbiddenTargetBranches: shipForbiddenTargetBranchesField,
-
- // Feature flag — when true, the shepherding handler uses the structural
- // probe verdict (`src/workflows/ship/verdict.ts` + `probe.ts`) as the
- // terminal-readiness signal. When false, the legacy in-process
- // review/resolve loop runs unchanged. Defaults off for safe rollout
- // (research.md R8 cutover plan); flipped on after Phase 1+2 soak.
- shipUseProbeVerdict: z.boolean().default(false),
-
- // Feature flag — when true, the shepherding handler releases the
- // daemon slot between iterations and re-enters via continuation
- // (Valkey `ship:tickle` + Postgres `ship_continuations`). When false,
- // the legacy in-process loop holds the slot for the full session.
- // Defaults off for safe rollout; flipped on after the probe verdict
- // path is validated.
- shipUseContinuationLoop: z.boolean().default(false),
})
.superRefine((data, ctx) => {
validateServerModeCredentials(data, ctx);
@@ -800,14 +785,6 @@ function loadConfig(): Config {
reviewBarrierSafetyMarginMs: process.env["REVIEW_BARRIER_SAFETY_MARGIN_MS"],
fixAttemptsPerSignatureCap: process.env["FIX_ATTEMPTS_PER_SIGNATURE_CAP"],
shipForbiddenTargetBranches: process.env["SHIP_FORBIDDEN_TARGET_BRANCHES"],
- shipUseProbeVerdict: parseBooleanEnv(
- "SHIP_USE_PROBE_VERDICT",
- process.env["SHIP_USE_PROBE_VERDICT"],
- ),
- shipUseContinuationLoop: parseBooleanEnv(
- "SHIP_USE_CONTINUATION_LOOP",
- process.env["SHIP_USE_CONTINUATION_LOOP"],
- ),
});
assertOauthRequiresAllowlist(cfg);
diff --git a/test/config.test.ts b/test/config.test.ts
index 6f6a1341..8f167e41 100644
--- a/test/config.test.ts
+++ b/test/config.test.ts
@@ -283,8 +283,6 @@ describe("configSchema — ship workflow defaults", () => {
expect(result.data.reviewBarrierSafetyMarginMs).toBe(1_200_000);
expect(result.data.fixAttemptsPerSignatureCap).toBe(3);
expect(result.data.shipForbiddenTargetBranches).toEqual([]);
- expect(result.data.shipUseProbeVerdict).toBe(false);
- expect(result.data.shipUseContinuationLoop).toBe(false);
}
});
@@ -435,27 +433,3 @@ describe("configSchema — SHIP_FORBIDDEN_TARGET_BRANCHES parsing", () => {
if (result.success) expect(result.data.shipForbiddenTargetBranches).toEqual([]);
});
});
-
-describe("configSchema — SHIP_USE_* feature flags", () => {
- it("defaults all three rollout flags to false", () => {
- const result = configSchema.safeParse({ ...ANTHROPIC_BASE });
- expect(result.success).toBe(true);
- if (result.success) {
- expect(result.data.shipUseProbeVerdict).toBe(false);
- expect(result.data.shipUseContinuationLoop).toBe(false);
- }
- });
-
- it("accepts boolean true overrides", () => {
- const result = configSchema.safeParse({
- ...ANTHROPIC_BASE,
- shipUseProbeVerdict: true,
- shipUseContinuationLoop: true,
- });
- expect(result.success).toBe(true);
- if (result.success) {
- expect(result.data.shipUseProbeVerdict).toBe(true);
- expect(result.data.shipUseContinuationLoop).toBe(true);
- }
- });
-});