Skip to content

Dev Lifecycle

TL;DR. Declare a project’s dev environment in dev.json; /dev-up brings it to a fully testable state — server up, migrations run, seed data loaded, test users provisioned, health checked. Warp-drive calls it automatically before the first chunk and between chunks.

Declarative dev environment management that ensures apps are always running, seeded, and testable — both on-demand and during warp-drive autonomous coding.

Every project declares its dev environment in dev.json at the project root. The bob dev up command reads this manifest and orchestrates the full lifecycle: server management, migrations, seed data, and test user provisioning. It’s idempotent, composable, and integrates directly into warp-drive.

Terminal window
# 1. Copy the template to your project
cp ~/.claude/templates/dev.json ./dev.json
# 2. Customize for your project (edit server command, port, auth adapter, etc.)
vi dev.json
# 3. Set up seed data
mkdir -p seed/
cp ~/.claude/templates/seed/users.json ./seed/
cp ~/.claude/templates/seed/001-base-data.sql ./seed/
cp ~/.claude/templates/seed/002-sample-entities.sql ./seed/
# Customize the SQL for your schema
# 4. Run it
~/.claude/scripts/dev-lifecycle/dev-up.sh . --verbose
{
"bob_version": "stable", // Optional (#2047): BoB pin — "stable" | "nightly" | "vX.Y.Z" | "nightly-YYYY-MM-DD"; bob ready refuses a machine whose deployed BoB does not satisfy it
"server": {
"command": "npm run dev", // Command to start the dev server
"port": 5173, // Port the server listens on
"health": "/api/health", // Health check endpoint path
"startup_timeout": 30, // Seconds to wait for post-launch health (default 30; DDEV/Docker cold starts need 60-120)
"restart": "on-failure", // Restart strategy: "on-failure" | "always" | "never"
"env": {} // Additional environment variables
},
"migrations": {
"command": "npm run migrate:dev", // Migration command (null to skip)
"auto_run": true // Run migrations automatically
},
"docs": { // Optional docs-site dev server (#732); omit for no docs server
"enabled": false, // Manage the docs server? (defaults true when the block is present)
"engine": null, // "vitepress" | "starlight" | null (resolve from docs-site.json)
"port": null, // null resolves the ledger docs port (base+3)
"health": "/", // Docs health-check path
"restart": "on-failure" // Restart strategy: "on-failure" | "always" | "never"
},
"seed": {
"directory": "seed/", // Path to seed scripts
"runner": "node", // Script runner: "node" | "npx tsx" | custom
"order": "alphabetical", // Execution order: "alphabetical" | "numeric"
"d1_database": "db", // D1 database name for SQL seeds (if applicable)
"auto_generate": true // Auto-generate seed data for uncovered tables during dev-up
},
"auth": {
"adapter": "d1", // Auth adapter: "d1" | "sqlite" | "supabase" | "script" | "custom"
"users_file": "seed/users.json", // Path to test user definitions; null = no test users (nothing generated or provisioned)
"table": "users", // Database table name for users
"db_file": "dev.db", // SQLite database file path (for the "sqlite" adapter)
"provision_command": null // Custom provisioning command (for "custom" adapter)
},
"access": {
"localhost": "http://localhost:5173", // Local access URL
"farm_01": null // Remote dev server URL (if accessible)
}
}

The full schema lives at schemas/dev.schema.json and is validated by make check. bob_version is read only by bob ready’s version step (never by bob dev up) — see Pinning a project to a version. The block above is the canonical single-environment shape; templates/dev.json is the scaffold bob dev up copies into new projects.

Multi-environment profiles & promotion (#447)

Section titled “Multi-environment profiles & promotion (#447)”

Beyond the dev-only fields above, dev.json may declare higher environments and a promotion policy. Both blocks are optional — a project without them is a single-environment (dev-only) project, and existing manifests keep validating unchanged.

{
"environments": {
"test": { // also: "prod", or any named env (e.g. "staging")
"url": "https://app-test.example.workers.dev",
"deploy": {
"adapter": "cloudflare", // "cloudflare" | "node" | "drupal" | "script" | "none"
"command": null, // override deploy command (script adapter)
"target": "app-test" // deploy target (e.g. Worker/env name)
},
"db": {
"binding": "DB", // binding name in this environment
"database": "app-db-test", // database identifier
"adapter": "d1" // "d1" | "sqlite" | "supabase" | "postgres" | "none"
},
"env": { "ENVIRONMENT": "test" } // env-specific variables
}
},
"promotion": {
"ceiling": "prod", // how far promotion may go: "pr" | "external" | "test" | "prod" (default "external")
"strategy": "ci" // how promotion is triggered: "manual" | "auto" | "ci" (refined in #448)
}
}
  • environments.{test,prod,…} — per-environment deploy config, public URL, database binding, and env vars. The deploy-adapter interface is expanded in #450; the test-env provisioning + prod→test mirror in #451.
  • deploy (top level, #3080) — the project-wide adapter config the deploy readers use: adapter (what resolve.sh picks before stack detection), build, migrate, command, health.<env>, drush_alias, and the Cloudflare adapter’s fallback health URLs production_url / preview_url. The per-environment part stays under environments.<env>.deploy — for the Cloudflare adapter that is target, the wrangler env name. The schema declares exactly the keys the adapters read; tests/test-dev-json-deploy-key.sh validates a fixture and runs it through deploy.sh --dry-run.
  • environments.<env>.provision.r2_buckets (#3080) — the test R2 bucket names test-env.js provision creates next to the test D1; a second --apply reports each as exists and creates nothing.
  • Wrangler side — wrangler.jsonc included (#3080). bob promotion setup (its wrangler step, scaffold-wrangler.js) gives the wrangler config an env.test and an env.prod block. It reads wrangler.json / wrangler.jsonc / wrangler.toml through wrangler-config.js — the reader the Cloudflare deploy adapter plans from — so both parse a config the same way. A commented wrangler.jsonc is edited in place by inserting the new members (jsoncInsertMember), never re-serialised, so every comment survives; TOML is appended to. env.test names each resource <name>-test (D1, R2 buckets, Analytics Engine datasets), copies Images and vars with ENVIRONMENT = "test"; env.prod repeats the top-level bindings and Worker name. Under --apply the test D1 id comes from wrangler d1 list and each test bucket is checked against wrangler r2 bucket list; a missing one is reported by name, never invented. Bindings with no test twin (KV, Durable Objects, queues, …) are reported as unmapped. See Promotion-pipeline setup → Step 4. Wrangler inherits routes and triggers into a named env, so a Worker with production routes or crons needs "routes": [] and "triggers": {"crons": []} in env.test — added by hand until #4192 — or its first test deploy takes the production routes.
  • Worker with assets on wrangler.jsonc (#3069). The proven path for a single Worker that serves its own assets (example-awards is the reference): remove any second deploy path first (a dashboard Workers Builds integration, an old push-to-main deploy.yml), bob promotion setup --apply, add environments.test.url and environments.test.provision (d1_database, r2_buckets) by hand, provision, set each secret per env, then bob deploy --env test; bob deploy --env prod is refused below ceiling prod. See Promotion-pipeline setup → Worker with assets.
  • promotion — the project’s promotion-pipeline policy. ceiling (required when the block is present) caps how far automated promotion may go: pr (stop at a pull request), external (merge to git but no deploy — the default), test/prod (merge and deploy up to that environment). The merge/deploy decision is f(automation_level, ceiling), enforced by scripts/promotion/promotion.js; see automation-behavior.md for the matrix. Defined in #448.

Declared backup — environments.<env>.backup (#3083)

Section titled “Declared backup — environments.<env>.backup (#3083)”

A project’s data backup is declared on the environment it protects, next to that environment’s provision block: prod declares what is backed up, a lower environment is where a restore lands. The block is definitions.backup in schemas/dev.schema.json, allowed on every environment profile.

{
"environments": {
"prod": {
"url": "https://app.example.com",
"backup": {
"database": "app-db",
"bucket": "app-db-backups",
"schedule": "0 3 * * *",
"retention": { "dailies": 7, "monthlies": 12 },
"restore_target": "test"
}
},
"test": {
"provision": { "d1_database": "app-db-test" }
}
}
}
Key Required Default Meaning
database yes — The D1 database to back up: its name or its Worker binding.
bucket no <database>-backups The in-account R2 bucket the exports land in. When database names a binding, bob backup setup uses the D1 database’s own name.
schedule no 0 3 * * * Five-field cron for the export (nightly, 03:00 UTC).
retention no { "dailies": 7, "monthlies": 12 } How many nightly and first-of-month exports to keep, applied as an R2 lifecycle rule.
restore_target no test The environment a restore drill or bob backup restore loads into. prod is refused.
media no [] Phase 2: R2 media buckets copied alongside the database.
offsite no none Phase 2: { "bucket", "account" }, the tenant-owned offsite copy.

Any other key is refused by the schema.

One reader. Every backup command reads the block through scripts/backup/declaration.js, which applies the defaults above and refuses a bad block with a named error: dev-json-missing, dev-json-invalid, backup-missing, backup-invalid or restore-target-prod. Its CLI shows what a project declares:

Terminal window
node ~/.claude/scripts/backup/declaration.js --env prod [--project DIR] [--json]
# exit 0 resolved · 2 usage error · 3 a named declaration error

tests/test-backup-declaration.sh validates fixtures, and the example above, against the schema and runs each through the reader.

The backup Worker — bob backup setup (#3084)

Section titled “The backup Worker — bob backup setup (#3084)”

bob backup setup renders the backup Worker for a project from its declared block. It is a dry run by default: it prints the files it would write, the bucket and the lifecycle rules, each with its action, and changes nothing. --apply does the work:

  1. Finds the declared database (a binding or a D1 name) in the project’s own wrangler.jsonc / wrangler.json / wrangler.toml, for its database_id, and takes the account from account_id (or CLOUDFLARE_ACCOUNT_ID).
  2. Renders templates/backup-worker/ into <project>/backup-worker/: the Worker <name>-backup, a cron trigger on schedule, the BackupWorkflow binding, the backup bucket bound as BACKUP_BUCKET, and the database’s id and name as vars.
  3. Creates the backup bucket when wrangler r2 bucket list does not show it.
  4. Sets the retention as two R2 lifecycle rules: bob-backup-daily expires <db>/ after dailies days, and bob-backup-monthly expires monthly/<db>/ after monthlies × 31 days (no monthly rule when monthlies is 0). A rule that differs is removed and added again. Rules with other names are left alone.

A second --apply prints no change. The command never deploys the Worker and never sets its secret: it prints both as next: steps. Exit codes: 0 planned or applied, 1 a wrangler step failed under --apply, 2 usage, 3 the declaration, the wrangler config or its D1 entry is missing (named on stderr). --json prints one bob-backup-setup/1 document.

What one run writes. Each night the Workflow starts a D1 export through the REST API (output_format: polling), polls it with exponential backoff (8 retries), and streams the dump from the signed URL into the bucket:

Key Content
<db>/<YYYY-MM-DD>T<HHMM>Z.sql The SQL dump, byte for byte.
<db>/<YYYY-MM-DD>T<HHMM>Z.json The sidecar, bob-backup-sidecar/1: database, key, taken_at, size (bytes), tables (INSERT rows per table) and bookmark (the export’s source bookmark).
monthly/<db>/… On the 1st of the month, a copy of both, kept by the monthly rule.
latest/<db>.json Written last, once everything above landed: a copy of the newest sidecar. wrangler cannot list a bucket, so this pointer is how bob backup status, run and restore find the newest dump. It sits outside <db>/, so no lifecycle rule expires it.
latest/<db>.drill.json The last restore drill, when one records itself (cap #3067 phase 2); bob backup status shows “none recorded” until then.

A failed step fails the run loudly. An export that the API reports as failed is not retried. A step that fails deletes what it wrote, so no partial object stays under the dated key. The logic is in templates/backup-worker/src/backup.js; tests/test-backup-worker.js drives it against a mocked export API, including a poll that never completes.

Status, run and restore — bob backup status | run | restore (#3085)

Section titled “Status, run and restore — bob backup status | run | restore (#3085)”
Command What it does Exit codes
bob backup status [--json] Per env that declares a backup: the newest dump, its age, size, the sidecar’s row counts and the last drill. Fresh when it was taken no earlier than the run the schedule last fired, allowing that run an hour. Each env has a state and the document a verdict: fresh, stale (past the run, or no backup yet) or unknown (the bucket could not be read — never fresh, #3086). When an env is stale, status reads the backup Workflow’s latest instance (wrangler workflows instances describe <worker> latest) and records it as latest_run ({instance, status, at, error}); when that run errored or was terminated, the reason names it — its instance, state, start time (UTC) and error — instead of no backup yet (#4201). 0 fresh · 1 stale, absent or unknown · 3 no backup declared
bob backup run [--env prod] [--timeout 30] Triggers the backup Workflow (wrangler workflows trigger <name>-backup) — the same job the cron starts — and waits for latest/<db>.json to name a dump taken after the trigger. runBackupNow() in scripts/backup/ops.js is the library entry the deploy path calls. 0 landed · 1 trigger failed or timed out · 3
bob backup restore [--env test] [--from <key>] Fetches the newest dump (or <key>) and its sidecar, drops the target D1’s tables and views, loads the dump with wrangler d1 execute --file, and checks each table’s row count against the sidecar. Prints the elapsed time as the measured RTO. The target is the source’s binding in env.<restore_target>. 0 restored and verified · 1 a step failed or a count differs · 3 · 4 refused by the prod gate

The prod gate. A restore whose target is prod — by name, or because it is the database the backups are taken from — is refused unless a human passes the gate. It is never run when _workflow.unattended is set. It needs automation Level 1 or --i-understand-this-overwrites-prod, and then the database name typed at the terminal. A refusal touches nothing. The procedure, D1 Time Travel first, is the Project Data Restore runbook. tests/test-backup-ops.sh drives all three against a stub wrangler whose D1 is real SQLite.

Freshness in the snapshot, bob status, bob tick and bob brief (#3086)

Section titled “Freshness in the snapshot, bob status, bob tick and bob brief (#3086)”

A backup that stops running is invisible until something measures its absence. One reader, scripts/backup/freshness.js, turns bob backup status --json into a compact record, and every surface below uses it:

Field Meaning
declared always true; the record is null when dev.json declares no environments.<env>.backup
verdict fresh · stale · unknown; with several declared envs, the worst (stale > unknown > fresh, then the oldest)
stale true / false, or null when unknown — an unreadable bucket is never counted fresh
newest_at, age_hours, size the newest dump’s taken_at, its age, its size in bytes, from latest/<db>.json
due_at the scheduled run the newest dump is measured against — for a stale backup, the run it missed
reason, source why it is not fresh; the pointer read (r2://<bucket>/latest/<db>.json)
envs each declared env’s own verdict
  • Fleet snapshot. audit.js snapshot carries it as backups on every present project, rolls it up as summary.backups (declared, fresh, stale, unknown, with names) and audit.js view prints a Backups table. BOB_FLEET_BACKUP_PROBE=0 skips the bucket read and records a declared backup as unknown. See Fleet Snapshot Timer.
  • bob status --scope project. A backups section, graded when a backup is declared: fresh ok, stale drift, unknown error. See bob status.
  • The missed-backup notice. scripts/backup/notice.js reads the snapshots and sends one bob notify (level warn) per missed run, keyed <project>|<env>|<due_at> in $BOB_HOME/.backup-notices.json. The same miss is noticed once however many ticks, briefings or checkouts report it; the next missed run is a new notice; a transport failure (exit 4) is retried on the next run; unknown is reported but never noticed. bob tick runs it after its snapshot (step 5c) and bob brief --send before the briefing, which also lists a backup-missed line under Needs you (briefing). Without --apply it reports and sends nothing.

tests/test-backup-freshness.sh drives the snapshot, the view, bob status, the notice and the briefing over fresh, stale, undeclared and unreachable projects against a stub wrangler.

The secret the Worker needs. D1_REST_API_TOKEN is a Cloudflare API token with Account · D1 · Edit on the project’s account and nothing else. The D1 export API (POST /accounts/{id}/d1/database/{id}/export) refuses a Read token: every Workflow run fails at start export-1 with D1 export API: Authentication error (#4201). Set it with wrangler secret put D1_REST_API_TOKEN --config backup-worker/wrangler.jsonc. The Worker writes to R2 through its binding, so it needs no R2 token. Declare the token in secrets-inventory.json as <project>-d1-backup-token, with a prod environment and a wrangler-secret location <name>-backup:D1_REST_API_TOKEN. make check-secrets-inventory also refuses a secret-shaped line in the template (template-secret).

The optional docs block lets bob dev up bring a project’s documentation site up alongside the app server. When the block is present and not disabled, bob dev up starts /docs-site dev on the project’s ledger docs port (base+3), reports its URL, and health-checks it.

  • Opt-in. Omitting the block — or setting enabled: false — means no docs server; the rest of the lifecycle is unchanged. templates/dev.json ships the block inert (enabled: false) for discoverability.
  • Port. port: null resolves the ledger docs port (base+3), so the docs server never collides with the app (base+0/+1), the dashboard (+2), or another project. A numeric port overrides.
  • Engine. engine: null resolves the engine from the project’s docs-site.json; starting the server requires that file (run /docs-site init first).
  • Non-fatal. A docs-server start or health failure is surfaced (logged, and reported in the --check JSON under docs) but is never fatal for the app — the app’s health verdict and exit code are unaffected. Between warp-drive chunks, bob dev up --check verifies the docs server when declared and flags a failure without blocking the app.
{
"docs": {
"enabled": true,
"engine": null, // resolve from docs-site.json
"port": null, // resolve the ledger docs port (base+3)
"health": "/",
"restart": "on-failure"
}
}

dev.json is a committed, vetted file — generated once from a stack-keyed recipe, then owned by the project. bob dev up uses the committed file; it does not regenerate it on every run. This replaced the old on-the-fly generation in the bob dev up entrypoint (now scripts/cli/dev-up) so warp-drive gets a stable, project-specific bring-up.

Recipes live in templates/dev-recipes/ — one JSON file per stack (cloudflare-d1, sveltekit, drupal-ddev, node, …). Each is a dev.json template plus a _recipe metadata block:

{
"_recipe": { "name": "cloudflare-d1", "stacks": ["cloudflare-workers"], "priority": 20 },
"server": { "command": "npx wrangler dev", "port": "${PORT}", ... },
"seed": { "d1_database": "${D1_DATABASE}", ... },
...
}

The engine (scripts/dev-lifecycle/dev-recipe.js):

  1. Detects the stack by reusing the fleet readiness audit’s detector (scripts/fleet/readiness.js::detectStack) — there is no second stack detector to keep in sync.
  2. Selects the highest-priority recipe whose _recipe.stacks intersect the detected stack (so a Cloudflare+Node project picks cloudflare-d1, priority 20, over node, priority 5).
  3. Substitutes placeholders (${PORT}, ${D1_DATABASE}) from lightly-detected values, strips _recipe, and writes a schema-valid dev.json.

When no recipe matches (e.g. a CLI/tooling repo with no dev server), or the repo gitignores dev.json to opt out, there is no dev environment to bring up. Nothing is written — no spurious dev.json — and both entrypoints give one result (#853): bob dev up and scripts/dev-lifecycle/dev-up.sh print dev-up: no dev environment to bring up — skipping and exit 0. bob dev health (--check) reports {"status":"n-a"} with exit 0, never unhealthy; the fleet readiness grade calls the same project n-a, distinct from broken. A project whose stack does have a recipe but no dev.json yet is still an error from the engine: bob dev up seeds one.

Two guards keep seeding scoped to repos that actually run a root dev server (#1573):

  • Nested-only workers don’t trigger a root recipe. A worker under apps/<name>/ makes detectStack report cloudflare-workers (correct for deploy-path grading), but with no root wrangler config a root wrangler dev cannot run — recipe selection drops the token and the repo skips (“no dev environment”).
  • A gitignored /dev.json is an opt-out. A repo that gitignores dev.json has deliberately declined a committed dev environment; seeding is skipped rather than re-creating the file every run. An explicit bob dev up --regen (write --force) still overrides.
Command Effect
bob dev up [dir] Use the committed dev.json; seed one once from the recipe if missing
bob dev up --regen [dir] Rewrite dev.json from the stack recipe (overwrites)
dev-recipe.js detect|render|write [--force]|list [dir] Inspect/drive recipes directly

Adding a stack is a pure data change — no engine edits:

  1. Drop a new templates/dev-recipes/<name>.json file.
  2. Give it a _recipe block: name, the stacks it applies to (matching detectStack tokens such as sveltekit, drupal, go, python), and a priority (higher wins ties; specific stacks should outrank generic node).
  3. Author the dev.json body, using ${PORT} / ${D1_DATABASE} where detection should fill values.

The engine discovers it automatically on the next run; the new recipe’s rendered output is validated against dev.schema.json by the test suite.

Moved — see Port Allocation.

All scripts live in ~/.claude/scripts/dev-lifecycle/ and follow the IaC prime directive: standalone, idempotent, parameterized, with --help.

Script Purpose Usage
dev-up.sh Full lifecycle orchestrator dev-up.sh [DIR] [--check|--verbose|--skip-*]
health-check.sh Lightweight health probe health-check.sh [DIR] [--port N --path /path]
provision-users.sh Test user provisioning provision-users.sh [DIR] [--adapter TYPE]
seed-generate.sh Seed SQL from schema discovery, and seed/users.json from the operator’s superuser or the default (#2272) seed-generate.sh [DIR] [--regen|--dry-run]
check-seed-coverage.sh Seed data coverage check check-seed-coverage.sh [DIR]

The main orchestrator. Runs the full lifecycle:

  1. Server: Check health → start if not running → kill & restart if unhealthy
  2. Migrations: Run migration command if configured and auto_run is true
  3. Seed data: Execute all scripts in seed/ directory alphabetically
  4. Test users: Provision users via the configured auth adapter — first logging which superuser it is provisioning and where it came from (Superuser: <email> — source: …: the operator profile’s seed.superuser, the product default, or a project-authored users.json), and refusing with exit 5 when the superuser’s password_secret names an environment variable that is unset (#2272)
  5. Report: Output JSON status and access URL — with a superuser object (email, source, and password_secret when the password is a secret name) once the test-users step ran

Flags:

  • --check — Health check only (no start/seed/users). Fast gate for warp-drive.
  • --skip-seed — Skip seed data scripts
  • --skip-users — Skip user provisioning
  • --skip-server — Skip server management (run seed/users only)
  • --verbose — Show detailed step-by-step output

Lightweight standalone health probe. Returns JSON:

{"status": "healthy", "port": 5173, "url": "http://localhost:5173"}

Reads seed/users.json and provisions users via pluggable adapters:

Adapter How it works
d1 Generates SQL, executes via wrangler d1 execute --local
sqlite Direct sqlite3 INSERT OR REPLACE
supabase Delegates to supabase db reset --local
script Runs project-local seed/provision-users.sh
custom Runs auth.provision_command from dev.json

A user entry may carry password_secret — the name of an environment variable holding the password — instead of a literal password (#2272). This is what seed-generate.sh writes for an operator-declared superuser, so no password lands in a committed file. The d1 and sqlite adapters read the variable at insert time; the script adapter hands the project script a resolved temporary copy of the users file (the project’s own users.json is never rewritten); a custom command reads the variable itself. An unset variable fails provisioning naming it — a password is never guessed. The result line reports the superuser and its source: {"status":"ok","users":4,"adapter":"script","superuser":{"email":"…","source":"…"}}.

Besides the seed SQL it derives from schema discovery, seed-generate.sh writes seed/users.json when the project has none, from templates/seed/users.json with the superuser resolved once:

Resolved operator (operator.md) Superuser written _superuser_source
declares seed.superuser its email, with password_secret = the declared secret name and no password key operator <id> (operator.json seed.superuser; password from $NAME)
resolved, no seed.superuser admin@test.local / admin123 default (operator <id> declares no seed.superuser)
none resolved admin@test.local / admin123 default (no operator resolved)

An existing users.json is project-authored and never rewritten (delete it to regenerate); --dry-run names what would be created. The operator is found through scripts/lib/operator-resolve.sh, never by path.

Advisory check: warns if migration/schema files changed but no seed files were modified. Used by warp-drive during the coding phase to remind about additive seed data.

See ~/.claude/templates/seed/README.md for full details.

Key rules:

  • Files run in alphabetical order. Use numeric prefixes: 001-base.sql, 002-entities.sql
  • Every script MUST be idempotent (INSERT OR REPLACE, UPSERT, etc.)
  • Seed entities in ALL lifecycle states your domain defines — not just fresh/active records
  • New features add their own seed file in the same commit (e.g., 010-feature-invoicing.sql)

Defined in seed/users.json, generated by seed-generate.sh when absent. The superuser is the same on every project an operator runs — a default the operator may override once, in its profile, never per project (#2272):

Role Email Password Purpose
superuser admin@test.local admin123 Full access — the product default; an operator’s seed.superuser replaces it
editor editor@test.local editor123 Read/write/publish access
viewer viewer@test.local viewer123 Read-only access
guest guest@test.local guest123 Minimal/no access

To override the superuser, declare it in operator.json — an email and the name of a secrets[] entry holding the password, never a literal:

"seed": { "superuser": { "email": "admin@example.test", "password_secret": "DEV_SUPERUSER_PASSWORD" } },
"secrets": [ { "name": "DEV_SUPERUSER_PASSWORD", "source": "env" } ]

Every users.json generated from then on carries that email with password_secret (no password key), and bob dev up reads the value from that environment variable when it provisions the users — refusing, by name, when it is unset. bob dev up reports the superuser it provisioned and its source on every run (Superuser: … — source: …). Projects customize the role-specific users to match their permission model; the superuser is left to the operator.

When a project has dev.json, warp-drive automatically manages the dev environment:

Phase Action
prerequisites Full bob dev up --verbose — start everything before coding begins
coding check-seed-coverage — advisory reminder if schema changes lack seed data
chunk_complete bob dev up --check — fast health gate before next chunk
chunk_complete (recovery) Full bob dev up --verbose if health check fails

Dev health failure is treated as a blockable event. If recovery fails after 2 attempts, warp-drive escalates per its error protocol.

When a docs block is declared, the same bob dev up --check gate also verifies the docs server and surfaces its status under docs in the JSON output. A docs-server failure is reported but is not treated as an app health failure — it never blocks the chunk gate on its own.

Projects without dev.json skip all dev lifecycle steps — the integration is opt-in.

The loop and its sibling machines keep their state in .claude/ (and, at the project root, the generated dashboard .dash/ and dev-up’s .dev-server.log / .pid), declared once in scripts/lib/derived-paths.json session_state — so the managed ignore block lists them and the segmentation checkpoint never counts them as in-flight work:

File Owner
.claude/.warp-drive-state.json (+ .lock) warp-drive
.claude/.decompose-state.json (+ .lock) bob decompose (#2973) — runs beside warp-drive
.claude/.do-state.json (+ .lock) bob do (#2974) — refused while a warp-drive run is active
.claude/.provision-overlay.json the session provision overlay
.claude/.last-hook-payload.json the detector hooks’ last payload — paths and key shape only, never a file body, mode 600 (#3617)
.claude/harness-defects/ the payload copies the state machine attaches to a harness-defect entry (#1839), mode 600 (#3617)
.claude/.project-ready.json bob ready’s last verdict
.claude/session-ledger.jsonl the session evidence ledger (session-ledger-append.sh, #1131) — append-only (#3096)
.claude/.decisions.jsonl (+ .lock, .lockdir/), .decisions.archive.jsonl, .distill-due, .self-updates.jsonl the decision log, its lock (#3124) and its distillation (capture-decision.sh, log-decision.sh, distill.sh, self-update.sh) — derived whether the project tracks the log or not (#3096)
.claude/loop-notes.json loop memory (loop-notes.js, #588) — see Loop memory (#3076)
.claude/.defect-status.json the defect-status forge cache (scripts/lib/defect-status.js, read by the loop-notes retire sweep) (#3077)
.dash/ bob dash generate output at the project root — regenerated on demand (#3077)
.claude/.provision-preflight.json the provisioning preflight’s session waiver memory (provision-preflight.js) (#3167)
.dev-server.log, .dev-server.pid bob dev up’s server log and pid, at the project root (#3167)

The two sibling machines run on the shared transition engine — see The shared machine engine.

A project can declare the checks the warp-drive inner loop runs, turning the testing phase’s feedback from an LLM-supplied free-text string into a typed contract. Add a checks array to dev.json (or a sibling checks.json):

{
"checks": [
{ "name": "unit", "command": "npm test", "kind": "test" },
{ "name": "types", "command": "tsc --noEmit", "kind": "typecheck" },
{ "name": "lint", "command": "npm run lint", "kind": "lint" }
]
}

Each check has a name, a command, and a kind (test | typecheck | lint | build | smoke). The runner executes them and parses each result by kind:

Terminal window
node ~/.claude/scripts/warp-drive/run-checks.js --cwd . # exit 0 = all pass, 1 = a check failed
  • test — failing test names + count (vitest / jest / node:test).
  • typecheck — tsc diagnostics as {file, line, message}.
  • lint — eslint (stylish + compact) diagnostics.
  • build / smoke / unknown — a raw output tail.

Parsers degrade gracefully — an unrecognised runner yields a tail, never a crash. The runner emits a typed last_check_result (schema: schemas/last-check-result.schema.json) with a stable failure signature; warp-drive persists it to state and the stall guard compares it instead of an LLM string. The smoke kind lets a change-scoped functional assertion (not just “server up”) gate a chunk.

Backward compatible: with no checks block the runner reports fallback: true and warp-drive runs the detected test command as before. See the warp-drive testing phase for the loop wiring.

Fallback made visible (#1979). A project with a browser-driven suite — package.json scripts named test:e2e* / *e2e* / *visual* / *playwright*, or a playwright.config.* — but no checks block runs that whole suite on every chunk. bob ready reports it as an advisory on the manifests step (checks: undeclared (wholesale fallback) — … found, with an Advisory: line naming the fix); it is never a blocker. Every templates/dev.json and templates/dev-recipes/*.json carries a _checks_example block — a worked, schema-validated example of a scoped checks block for that stack (the drupal-ddev recipe: phpunit → chunk on docroot/modules/custom/** with quarantine: false, jest → chunk on **/*.js, e2e smoke → requirement on theme templates / scss / js, visual full → release). The runner never reads _checks_example; copy the entries you want into checks to activate them.

Scoping checks to the change (paths, tier, timeout) (#1979)

Section titled “Scoping checks to the change (paths, tier, timeout) (#1979)”

By default every declared check runs on every chunk. On a project with a heavy suite (a 30-minute visual-regression tier, a make test of two hundred files) that is the dominant cost of a chunk, and most of it exercises nothing the chunk touched. Three optional fields on each check keep testing thorough for the change that was made:

{
"checks": [
{ "name": "phpunit", "command": "ddev exec phpunit", "kind": "test",
"paths": ["docroot/modules/custom/**"] },
{ "name": "jest", "command": "npm test", "kind": "test",
"paths": ["**/*.js"], "timeout": 300000 },
{ "name": "e2e-smoke", "command": "npm run test:e2e -- --grep @smoke", "kind": "smoke",
"tier": "requirement", "paths": ["web/themes/custom/**/*.{twig,scss,js}"] },
{ "name": "visual-full","command": "npm run test:visual", "kind": "smoke",
"tier": "release" }
]
}
Field Meaning Absent
paths Globs (**, *, ?, {a,b}; a glob with no / also matches by basename), project-relative. The check runs only when at least one changed file matches. always runs
tier Which warp-drive phase may run it: chunk — every testing phase; requirement — once per work item, in qa; release — never inside warp-drive (the promotion pipeline owns it). chunk
timeout Per-run wall-clock limit in ms; exceeding it ends the run with status error. 600000
quarantine false opts the check out of the flaky-on-base quarantine — for a check that cannot run outside the main tree (one executed inside a container bound to the project directory), where a base worktree could never pass and every failure would be mis-read as pre-existing. true

Changed files are the working tree (tracked modifications + untracked, renames by their new name) ∪ git diff --name-only <base>...HEAD. The base is the warp-drive state file’s recorded merge target / main branch, else the remote’s default branch, else main/master — or --base <ref> explicitly.

Terminal window
node ~/.claude/scripts/warp-drive/run-checks.js --cwd . # tier chunk, scoped to the change
node ~/.claude/scripts/warp-drive/run-checks.js --cwd . --tier requirement # what qa runs
node ~/.claude/scripts/warp-drive/run-checks.js --cwd . --all # disable path scoping
node ~/.claude/scripts/warp-drive/run-checks.js --cwd . --format human # ran vs skipped at a glance

Nothing is dropped silently. Every declared check appears in the result — the ones that did not run carry status: "skipped" and a skip_reason: scope (no changed file matched paths), tier (declared above the requested tier), budget (the testing budget ran out before it was launched), or quarantined (it fails on the unmodified base too). The result also carries skipped[], tier, changed_files and base so the chunk report and the commit’s Tests-Skipped: trailer can show what was not exercised. Skipped checks never count as failures and never move the stall-guard signature.

Library callers that pass no changedFiles get the unscoped pre-#1979 behaviour (every paths check runs); the tier filter always applies. bob ready validates the three fields (a malformed tier/paths/timeout is a manifests blocker) and summarises how many checks are path-scoped or above the chunk tier.

Tier per phase. Warp-drive’s testing phase runs tier chunk on every chunk; its qa phase runs tier requirement once per work item; a release-tier check never runs inside warp-drive — it belongs to the promotion pipeline. The engine turns the result into the audit trail: the committing instruction carries the exact Tests-Skipped: trailer line the commit body must end with, and the reporting instruction carries the ran-vs-skipped list for a “Checks” section in the chunk report.

Testing budget (testing_budget_minutes) (#1979)

Section titled “Testing budget (testing_budget_minutes) (#1979)”

The whole testing pass is bounded by _workflow.testing_budget_minutes in .claude/settings.local.json (default 10; 0 = unlimited). It is a budget that actually bites — unlike max_phase_minutes, which only warns:

  • once the budget is spent the runner stops launching checks; each remaining one is recorded skipped: budget;
  • a check still running when the budget ends is cut (its own timeout is clamped to what is left) and recorded status: timed_out with summary.timed_out: {limit: "budget", elapsed_ms, limit_ms} (#4449) — a partial run is not evidence either way, so it gets no base run and does not fail the pass, and budget.exhausted is true;
  • the phase advances on the result of what ran; the result carries budget: {ms, exhausted, elapsed_ms}.
Terminal window
node ~/.claude/scripts/warp-drive/run-checks.js --cwd . --budget-minutes 5 # override the setting for one run
node ~/.claude/scripts/warp-drive/run-checks.js --cwd . --budget-minutes 0 # unlimited

Never silently: the skipped checks appear in the chunk report’s “Checks” section and as a Tests-Skipped: <name> (budget), … trailer in the commit body. A per-check timeout that fires while the budget still has room is a different thing — that check is status: timed_out with limit: "timeout" and fails the pass.

A timeout is told by the runner’s own timer (ETIMEDOUT), never by a missing exit code: a command that handles SIGTERM — wrangler, vitest, npm — exits 1 when it is killed, and before #4449 that read as a plain fail with no test named, followed by a base run that said “this change broke it”. A timed_out check’s note says so in plain words: nothing failed, the check ran out of time; speed it up or raise its limit. Every check that ran also records duration_ms.

The testing phase watches how long each check takes, so a suite that creeps from seconds to minutes one requirement at a time is caught before it breaks a run.

  • Every check that ran records duration_ms in the result. On every tests_passed / tests_failed the engine adds those durations to the project’s history, .claude/.check-durations.json (check-durations/1, the newest 50 runs per check; derived state, gitignored).
  • A declared limit fails the pass as too slow. A check that passes but takes longer than its own max_seconds (in dev.json checks), or else _workflow.check_duration_limit_seconds (default 0 = no limit), is recorded status: too_slow with summary.too_slow: {duration_ms, limit_seconds, source}. The note names the check, its duration and the limit and says to speed the suite up — no test failed, so there is nothing to hunt for.
  • Growth raises an advisory first. A check whose duration is over _workflow.check_duration_growth_ratio (default 2; 0 = off) times the median of its last _workflow.check_duration_window (default 10) recorded runs — with at least 3 earlier runs — is named in the chunk report instruction (CHECK DURATION ADVISORY) and on the Check time: line of bob warp status. It never blocks.
{ "name": "e2e", "kind": "test", "command": "npm run test:e2e", "max_seconds": 120 }
Terminal window
node ~/.claude/scripts/warp-drive/check-durations.js show --cwd . # each check's last run and median

bob warp status also prints this run’s total testing time per check, and warp viz splits the testing phase by check in Time by phase.

A check that fails on the change under test but also fails on the unmodified base is not evidence about the change — it is a pre-existing defect (or a non-deterministic suite), and the loop must not spend retries on it. When a check fails, before any retry loop the runner:

  1. materialises the branch base as a detached worktree (git worktree add --detach of the resolved base; node_modules / vendor from the main tree are symlinked in so the same command can execute) and runs the check there once;
  2. compares the failures — failing test ids for test, diagnostics by file + message for typecheck / lint, otherwise a matching non-zero exit code and matching output (numbers and the two tree roots normalised away). A check that reads a build output fails on the never-built base with a different message, so it is not quarantined (#3078);
  3. same failure on base → the check is recorded status: skipped, skip_reason: quarantined, with summary.quarantine (fingerprint, base and its sha, both exit codes, both output tails). It never blocks tests_passed and never moves the stall-guard signature; passes on base → a real regression: the failure stands, with summary.base_run.verdict: "passes on base — this change broke it"; a different failure on base → the HEAD failure stands, not quarantined; base unavailable (no git, unknown ref, worktree refused) → no quarantine attempted; summary.base_run.unavailable says why.

Re-runs of any one check are capped at 2 per chunk — the #588 known-flaky retry and the base run each count one. The count rides on each entry as reruns; the runner reads the previous pass’s value back from the state file’s last_check_result (cleared at every chunk boundary, so the cap is naturally per-chunk) and at the cap records summary.rerun_cap and lets the failure stand.

Terminal window
node ~/.claude/scripts/warp-drive/run-checks.js --cwd . # quarantine on (base from the state file / default branch)
node ~/.claude/scripts/warp-drive/run-checks.js --cwd . --no-quarantine # judge every failure on HEAD alone

The bug gets filed. At tests_passed / tests_failed the engine runs scripts/warp-drive/quarantine-report.js, which files a bug on the project repo (from its origin) per quarantine fingerprint (check name + command) — body marker <!-- quarantined-check: <fp> -->, the command, both results, and what to do — or bumps the existing open one (recurrence line

  • comment) instead of duplicating it. The issue number is stamped back onto summary.quarantine.issue, so the chunk report’s “Checks” section links it (skipped — quarantined (fails on main too, bug #N)). Best-effort: _workflow.quarantine_bug_filing: false turns it off, a degraded forge spools the create through the bin/gh shim, and a filing error is reported on the transition (quarantine_filing) but never blocks. This encodes the example-cms#1209 investigation so it never has to be repeated by hand.

Projects without a checks block get the same rule as prose: the testing phase tells the agent to run a failing test once on the unmodified base (git stash push -u / a base worktree) before any retry, file the bug if it fails there too, and cap re-runs at 2.

The loop records the quirks it discovers (flaky tests, legacy quirks, constraints, gotchas) in a per-project episodic-memory store so the next run starts smarter — the Reflexion pattern. Unlike journal/lessons/trace-mining (which flow outward to humans and the BoB repo), this store is read back by the same project’s loop and is agent-consumed, not filed as issues.

The store lives at .claude/loop-notes.json (schema: schemas/loop-notes.schema.json) and is git-ignored by default — it is machine state, declared in scripts/lib/derived-paths.json so the managed ignore block lists it and a segmentation checkpoint never refuses it (#3076). A team that wants shared hidden rules commits it once with git add -f .claude/loop-notes.json; git applies ignore rules only to untracked files, so once tracked it is committed like any other file.

Terminal window
# record a discovered rule (kind: flaky-test | legacy-quirk | constraint | gotcha)
node ~/.claude/scripts/warp-drive/loop-notes.js add --kind flaky-test \
--summary "test/api.test.js > fetches catalog" --evidence "passed on retry" --source-chunk 2
node ~/.claude/scripts/warp-drive/loop-notes.js list # inspect the store
node ~/.claude/scripts/warp-drive/loop-notes.js flaky # flaky-test identifiers

Capture is bounded (capped, oldest evicted) and de-duped by signature (kind + normalized summary; a repeat refreshes last_seen_at and increments count). Two consumers read it back:

  • Session start — the loop-notes-inject.sh SessionStart hook injects the notes as additionalContext, so the next run does not re-learn a known quirk.
  • Check-runner — a flaky-test note (its summary is the failing test id) lets the structured check-runner quarantine that test: a failing run whose failures are all known-flaky is retried once and, if it then passes, surfaced as quarantined rather than treated as a hard failure (still surfaced, never silently ignored).

A note that records a workaround for a BoB defect (“X didn’t fire — do it by hand”) must not outlive the bug. Tag it with the defect it compensates for and the store makes it visibly provisional and self-expiring:

Terminal window
node ~/.claude/scripts/warp-drive/loop-notes.js add --kind gotcha \
--summary "committing hook missed the commit — fired committed by hand" \
--defect paulirv/bigbrain#1830
node ~/.claude/scripts/warp-drive/loop-notes.js retire # drop notes whose defect is closed
node ~/.claude/scripts/warp-drive/loop-notes.js retire --json --refresh
  • --defect <owner>/<repo>#<n> is stored as defect: {repo, number} (schema schemas/loop-notes.schema.json); a malformed ref is rejected. harness-defect-report.js (#1836) passes it automatically when it records a compensation note, so every machine-recorded compensation is tagged.
  • Injection renders a tagged note as [workaround for open <repo>#<n>] and omits a note whose defect issue is closed. The filter lives in relevant() — the one seam injectText, list --format human and retire all read — so every reader agrees.
  • retire deletes the closed-defect notes and prints retired N workaround note(s) (fixed: #a, #b); warp-drive runs it at prerequisites and echoes that line in the phase output. Idempotent.
  • Closure lookup goes through scripts/lib/defect-status.js: one gh issue view … --json state per defect via the bin/gh shim, cached in .claude/.defect-status.json (git-ignored) for 6 hours (BOB_DEFECT_STATUS_TTL_MS overrides; --refresh forces a re-check) — so a session start checks each defect at most once. An unreachable forge is treated as open: the note is still injected and never retired on a failed lookup. Untagged notes are never touched.

The same defect: tag applies to auto-memory — see the Memory Conventions (defect: <repo>#<n> frontmatter) and /memory-audit, which flags tagged memories whose defect is closed and untagged workarounds.

retire only reaches notes that carry a defect: tag, and it only runs when warp-drive starts. An untagged note is never retired, and a project that never runs warp-drive never retires anything. review lists both, report-only, for the continuing-education rituals:

Terminal window
node ~/.claude/scripts/warp-drive/loop-notes.js review [--cwd <dir>] [--after-days <n>] [--now <iso>] [--json]
node ~/.claude/scripts/warp-drive/loop-notes.js retire --dry-run [--json] # what retire would drop; writes nothing
List A note is on it when
closed Its defect: issue is closed but the note is still in the store (what retire would drop).
due It is untagged, it was recorded at least after_days ago, and it has not recurred inside that window. The reason is never-recurred (count 1) or not-recurred-since (last seen before the window). A note that keeps recurring is live knowledge and is not listed.

after_days comes from --after-days, else _workflow.loop_note_review_after_days (workflow config, default 30), and 0 turns the untagged review off. review never writes. The comprehensive-review and vision-planning rituals carry both lists in their evidence packet (Rituals), and the attended session applies the retire.

Promotion — a recurring note becomes work (#2063)

Section titled “Promotion — a recurring note becomes work (#2063)”

Most notes stay memory. A note that keeps recurring is not memory any more: every run pays for it twice — once to trip over it, once to apply the workaround the note describes. Promotion turns it into work, so a laptop trip-up becomes something the farm can pick up unattended.

Terminal window
node ~/.claude/scripts/warp-drive/loop-notes.js promote # dry run — the plan
node ~/.claude/scripts/warp-drive/loop-notes.js promote --apply # file / bump
node ~/.claude/scripts/warp-drive/loop-notes.js promote --threshold 5 --json
node ~/.claude/scripts/warp-drive/loop-notes.js counts # distinct / promoted
  • Threshold. A note is promotable once its count reaches _workflow.loop_note_promote_at (default 3 — twice was coincidence, three times is a defect). 0 disables promotion entirely; --threshold N overrides for one run.
  • One issue per fingerprint. A bug labelled bug,harness-defect,p3-medium, carrying the marker <!-- loop-note: <sig> --> and the note’s kind, summary, evidence, first-seen/last-seen dates and recurrence count. On a farm-eligible project it also gets farm, which is what makes these pickup-able by the unattended tick.
  • Write-back closes the loop. The filed issue is written back onto the note as its defect: {repo, number} tag — so the retirement above applies: fix the bug, close it, and the note retires instead of being worked around forever. This is the mechanism, not bookkeeping; without it a promoted note would be promoted on every run and its workaround would outlive its cause.
  • Idempotent twice over. A note already carrying defect: is skipped (already-promoted). A note whose write-back was lost — a create the bin/gh shim spooled (durable, but no issue number to write back yet), a hand-edited store, a note re-added from scratch — is re-found by its marker and bumped, never duplicated.
  • Exit codes. On a dry run, 1 means “there is promotable work” — the signal /groom reads; on --apply, 1 means an edit failed. 2 is a usage error or an unresolvable repo: a project with no git origin reports it and files nothing rather than guessing where to file.

Two consumers, matching the rest of the store:

  • /groom Step 5g runs it every pass — dry with the plan shown at L1/L2, --apply at L3 — and reports the result line in its summary.
  • bob warp status shows the standing position as Loop notes: N distinct, M promoted, read from the store with no forge call.
  1. Copy ~/.claude/templates/dev.json to your project root
  2. Customize the server command, port, and health endpoint
  3. Create seed/ directory with your initial seed scripts
  4. Copy ~/.claude/templates/seed/users.json and customize roles
  5. Set the auth adapter in dev.json to match your stack
  6. Run bob dev up . --verbose to verify

A project provisioned before the authored-vs-derived rule (#1479) may still commit its .claude/ links, which leaves git dirt after every bob ready or bob provision refresh. Migrate it once with bob provision untrack --apply, which untracks only the provisioned symlinks, ignores them, and records one commit. Use --fleet to cover every checkout on the machine. See Authored vs. derived for the rule and the refusals (#2519).

The template at ~/.claude/templates/dev.json has sensible defaults for SvelteKit + Cloudflare Workers projects.

Moved to how-to — see Docs-Site Lifecycle.