Dev Lifecycle
Dev Environment Lifecycle (IaC)
Section titled “Dev Environment Lifecycle (IaC)”TL;DR. Declare a project’s dev environment in
dev.json;/dev-upbrings 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.
Overview
Section titled “Overview”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.
Quick Start
Section titled “Quick Start”# 1. Copy the template to your projectcp ~/.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 datamkdir -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 . --verbosedev.json Manifest
Section titled “dev.json Manifest”{ "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(whatresolve.shpicks before stack detection),build,migrate,command,health.<env>,drush_alias, and the Cloudflare adapter’s fallback health URLsproduction_url/preview_url. The per-environment part stays underenvironments.<env>.deploy— for the Cloudflare adapter that istarget, the wrangler env name. The schema declares exactly the keys the adapters read;tests/test-dev-json-deploy-key.shvalidates a fixture and runs it throughdeploy.sh --dry-run.environments.<env>.provision.r2_buckets(#3080) — the test R2 bucket namestest-env.js provisioncreates next to the test D1; a second--applyreports each asexistsand creates nothing.- Wrangler side —
wrangler.jsoncincluded (#3080).bob promotion setup(its wrangler step,scaffold-wrangler.js) gives the wrangler config anenv.testand anenv.prodblock. It readswrangler.json/wrangler.jsonc/wrangler.tomlthroughwrangler-config.js— the reader the Cloudflare deploy adapter plans from — so both parse a config the same way. A commentedwrangler.jsoncis edited in place by inserting the new members (jsoncInsertMember), never re-serialised, so every comment survives; TOML is appended to.env.testnames each resource<name>-test(D1, R2 buckets, Analytics Engine datasets), copies Images andvarswithENVIRONMENT = "test";env.prodrepeats the top-level bindings and Worker name. Under--applythe test D1 id comes fromwrangler d1 listand each test bucket is checked againstwrangler r2 bucket list; a missing one is reported by name, never invented. Bindings with no test twin (KV, Durable Objects, queues, …) are reported asunmapped. See Promotion-pipeline setup → Step 4. Wrangler inheritsroutesandtriggersinto a named env, so a Worker with production routes or crons needs"routes": []and"triggers": {"crons": []}inenv.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-maindeploy.yml),bob promotion setup --apply, addenvironments.test.urlandenvironments.test.provision(d1_database,r2_buckets) by hand, provision, set each secret per env, thenbob deploy --env test;bob deploy --env prodis refused below ceilingprod. 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 isf(automation_level, ceiling), enforced byscripts/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:
node ~/.claude/scripts/backup/declaration.js --env prod [--project DIR] [--json]# exit 0 resolved · 2 usage error · 3 a named declaration errortests/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:
- Finds the declared
database(a binding or a D1 name) in the project’s ownwrangler.jsonc/wrangler.json/wrangler.toml, for itsdatabase_id, and takes the account fromaccount_id(orCLOUDFLARE_ACCOUNT_ID). - Renders
templates/backup-worker/into<project>/backup-worker/: the Worker<name>-backup, a cron trigger onschedule, theBackupWorkflowbinding, the backup bucket bound asBACKUP_BUCKET, and the database’s id and name as vars. - Creates the backup bucket when
wrangler r2 bucket listdoes not show it. - Sets the retention as two R2 lifecycle rules:
bob-backup-dailyexpires<db>/afterdailiesdays, andbob-backup-monthlyexpiresmonthly/<db>/aftermonthlies× 31 days (no monthly rule whenmonthliesis 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 snapshotcarries it asbackupson every present project, rolls it up assummary.backups(declared, fresh, stale, unknown, with names) andaudit.js viewprints a Backups table.BOB_FLEET_BACKUP_PROBE=0skips the bucket read and records a declared backup asunknown. See Fleet Snapshot Timer. bob status --scope project. Abackupssection, graded when a backup is declared: freshok, staledrift, unknownerror. See bob status.- The missed-backup notice.
scripts/backup/notice.jsreads the snapshots and sends onebob 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;unknownis reported but never noticed.bob tickruns it after its snapshot (step 5c) andbob brief --sendbefore the briefing, which also lists abackup-missedline under Needs you (briefing). Without--applyit 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).
Docs-site dev server (#732)
Section titled “Docs-site dev server (#732)”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.jsonships the block inert (enabled: false) for discoverability. - Port.
port: nullresolves 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 numericportoverrides. - Engine.
engine: nullresolves the engine from the project’sdocs-site.json; starting the server requires that file (run/docs-site initfirst). - Non-fatal. A docs-server start or health failure is surfaced (logged, and reported in the
--checkJSON underdocs) but is never fatal for the app — the app’s health verdict and exit code are unaffected. Between warp-drive chunks,bob dev up --checkverifies 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-up recipes (#449)
Section titled “Dev-up recipes (#449)”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.
How it works
Section titled “How it works”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):
- 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. - Selects the highest-
priorityrecipe whose_recipe.stacksintersect the detected stack (so a Cloudflare+Node project pickscloudflare-d1, priority 20, overnode, priority 5). - Substitutes placeholders (
${PORT},${D1_DATABASE}) from lightly-detected values, strips_recipe, and writes a schema-validdev.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>/makesdetectStackreportcloudflare-workers(correct for deploy-path grading), but with no root wrangler config a rootwrangler devcannot run — recipe selection drops the token and the repo skips (“no dev environment”). - A gitignored
/dev.jsonis an opt-out. A repo that gitignoresdev.jsonhas deliberately declined a committed dev environment; seeding is skipped rather than re-creating the file every run. An explicitbob dev up --regen(write --force) still overrides.
Commands
Section titled “Commands”| 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 new stack recipe
Section titled “Adding a new stack recipe”Adding a stack is a pure data change — no engine edits:
- Drop a new
templates/dev-recipes/<name>.jsonfile. - Give it a
_recipeblock:name, thestacksit applies to (matchingdetectStacktokens such assveltekit,drupal,go,python), and apriority(higher wins ties; specific stacks should outrank genericnode). - Author the
dev.jsonbody, 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.
Port Allocation (#197)
Section titled “Port Allocation (#197)”Moved — see Port Allocation.
Scripts
Section titled “Scripts”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] |
dev-up.sh
Section titled “dev-up.sh”The main orchestrator. Runs the full lifecycle:
- Server: Check health → start if not running → kill & restart if unhealthy
- Migrations: Run migration command if configured and
auto_runis true - Seed data: Execute all scripts in
seed/directory alphabetically - 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’sseed.superuser, the product default, or a project-authoredusers.json), and refusing with exit 5 when the superuser’spassword_secretnames an environment variable that is unset (#2272) - Report: Output JSON status and access URL — with a
superuserobject (email,source, andpassword_secretwhen 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
health-check.sh
Section titled “health-check.sh”Lightweight standalone health probe. Returns JSON:
{"status": "healthy", "port": 5173, "url": "http://localhost:5173"}provision-users.sh
Section titled “provision-users.sh”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":"…"}}.
seed-generate.sh — test users (#2272)
Section titled “seed-generate.sh — test users (#2272)”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.
check-seed-coverage.sh
Section titled “check-seed-coverage.sh”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.
Seed Data Convention
Section titled “Seed Data Convention”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)
Standardized Test Users
Section titled “Standardized Test Users”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 | 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.
Warp-Drive Integration
Section titled “Warp-Drive Integration”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.
Session state files
Section titled “Session state files”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.
Structured checks (checks) (#586)
Section titled “Structured checks (checks) (#586)”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:
node ~/.claude/scripts/warp-drive/run-checks.js --cwd . # exit 0 = all pass, 1 = a check failedtest— failing test names + count (vitest / jest / node:test).typecheck—tscdiagnostics 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.
node ~/.claude/scripts/warp-drive/run-checks.js --cwd . # tier chunk, scoped to the changenode ~/.claude/scripts/warp-drive/run-checks.js --cwd . --tier requirement # what qa runsnode ~/.claude/scripts/warp-drive/run-checks.js --cwd . --all # disable path scopingnode ~/.claude/scripts/warp-drive/run-checks.js --cwd . --format human # ran vs skipped at a glanceNothing 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
timeoutis clamped to what is left) and recordedstatus: timed_outwithsummary.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, andbudget.exhaustedis true; - the phase advances on the result of what ran; the result carries
budget: {ms, exhausted, elapsed_ms}.
node ~/.claude/scripts/warp-drive/run-checks.js --cwd . --budget-minutes 5 # override the setting for one runnode ~/.claude/scripts/warp-drive/run-checks.js --cwd . --budget-minutes 0 # unlimitedNever 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.
Check durations (#4449)
Section titled “Check durations (#4449)”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_msin the result. On everytests_passed/tests_failedthe 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(indev.jsonchecks), or else_workflow.check_duration_limit_seconds(default0= no limit), is recordedstatus: too_slowwithsummary.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(default2;0= off) times the median of its last_workflow.check_duration_window(default10) recorded runs — with at least 3 earlier runs — is named in the chunk report instruction (CHECK DURATION ADVISORY) and on theCheck time:line ofbob warp status. It never blocks.
{ "name": "e2e", "kind": "test", "command": "npm run test:e2e", "max_seconds": 120 }node ~/.claude/scripts/warp-drive/check-durations.js show --cwd . # each check's last run and medianbob 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.
Flaky-on-base quarantine (#1979)
Section titled “Flaky-on-base quarantine (#1979)”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:
- materialises the branch base as a detached worktree (
git worktree add --detachof the resolved base;node_modules/vendorfrom the main tree are symlinked in so the same command can execute) and runs the check there once; - compares the failures — failing test ids for
test, diagnostics by file + message fortypecheck/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); - same failure on base → the check is recorded
status: skipped,skip_reason: quarantined, withsummary.quarantine(fingerprint, base and its sha, both exit codes, both output tails). It never blockstests_passedand never moves the stall-guard signature; passes on base → a real regression: the failure stands, withsummary.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.unavailablesays 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.
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 aloneThe 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: falseturns it off, a degraded forge spools the create through thebin/ghshim, 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.
Loop memory — hidden-rules store (#588)
Section titled “Loop memory — hidden-rules store (#588)”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.
# 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 2node ~/.claude/scripts/warp-drive/loop-notes.js list # inspect the storenode ~/.claude/scripts/warp-drive/loop-notes.js flaky # flaky-test identifiersCapture 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.shSessionStart hook injects the notes asadditionalContext, so the next run does not re-learn a known quirk. - Check-runner — a
flaky-testnote (itssummaryis 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).
Workaround provenance (#1837)
Section titled “Workaround provenance (#1837)”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:
node ~/.claude/scripts/warp-drive/loop-notes.js add --kind gotcha \ --summary "committing hook missed the commit — fired committed by hand" \ --defect paulirv/bigbrain#1830node ~/.claude/scripts/warp-drive/loop-notes.js retire # drop notes whose defect is closednode ~/.claude/scripts/warp-drive/loop-notes.js retire --json --refresh--defect <owner>/<repo>#<n>is stored asdefect: {repo, number}(schemaschemas/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 inrelevant()— the one seaminjectText,list --format humanandretireall read — so every reader agrees. retiredeletes the closed-defect notes and printsretired N workaround note(s) (fixed: #a, #b);warp-driveruns it atprerequisitesand echoes that line in the phase output. Idempotent.- Closure lookup goes through
scripts/lib/defect-status.js: onegh issue view … --json stateper defect via thebin/ghshim, cached in.claude/.defect-status.json(git-ignored) for 6 hours (BOB_DEFECT_STATUS_TTL_MSoverrides;--refreshforces 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.
Loop-note review (#2905)
Section titled “Loop-note review (#2905)”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:
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.
node ~/.claude/scripts/warp-drive/loop-notes.js promote # dry run — the plannode ~/.claude/scripts/warp-drive/loop-notes.js promote --apply # file / bumpnode ~/.claude/scripts/warp-drive/loop-notes.js promote --threshold 5 --jsonnode ~/.claude/scripts/warp-drive/loop-notes.js counts # distinct / promoted- Threshold. A note is promotable once its
countreaches_workflow.loop_note_promote_at(default 3 — twice was coincidence, three times is a defect).0disables promotion entirely;--threshold Noverrides for one run. - One issue per fingerprint. A
buglabelledbug,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 getsfarm, 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 thebin/ghshim 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,
1means “there is promotable work” — the signal/groomreads; on--apply,1means an edit failed.2is 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:
/groomStep 5g runs it every pass — dry with the plan shown at L1/L2,--applyat L3 — and reports the result line in its summary.bob warp statusshows the standing position asLoop notes: N distinct, M promoted, read from the store with no forge call.
Adopting in a Project
Section titled “Adopting in a Project”- Copy
~/.claude/templates/dev.jsonto your project root - Customize the server command, port, and health endpoint
- Create
seed/directory with your initial seed scripts - Copy
~/.claude/templates/seed/users.jsonand customize roles - Set the auth adapter in dev.json to match your stack
- Run
bob dev up . --verboseto 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.
Docs Site Lifecycle (#153)
Section titled “Docs Site Lifecycle (#153)”Moved to how-to — see Docs-Site Lifecycle.