# Weave development GitHub is the canonical repository. Pull requests replace the Gerrit review queue. Agents work in isolated branches and run targeted checks locally. A ready pull request lands on main after a fast `Dev gate` and deploys to dev.weave.grunion.ai within a minute. The full test inventory runs on main. The `promote` workflow moves production to the newest main commit that passed both the `CI gate` and the dev soak. ## Daily flow 1. Query the weave Development tracker before work and add the regression test before the fix. Keep one concern per worktree and coordinate file ownership. 2. Push the feature branch to origin early and open a GitHub pull request; the `PR gate` runs the tests on GitHub-hosted runners. Record the tracker reference, changes and test evidence. Never push directly to main. Local runs through the shared test manager are optional, for debugging a failure. Read the complete diff, including lifecycle, undo, CLI, API and MCP parity. Check both themes for UI changes and update the Handbook where behavior changes. 3. Self-review, then arm auto-merge: `gh pr merge --auto --squash`. Two checks are required: `Dev gate` (syntax plus a boot health check, under a minute) and `PR gate` (the `pr` workflow, under six minutes). The branch does not have to be up to date with main. 4. Fix a red `PR gate` by pushing again; a new push cancels the superseded run. 5. Every main push swaps dev.weave.grunion.ai to that commit. The dev supervisor boots the new commit beside the serving one, moves traffic only after its health check passes, and keeps the old worker serving when it fails. A failed commit is skipped until main moves. Roll back a bad change by landing its revert. The dev service runs `weave supervise` with `WEAVE_AUTO_UPDATE=1`, `WEAVE_TRACK=main` (follow main commits into `releases/c/`; after every swap, failed health check and boot the supervisor deletes every folder under `releases/` except the serving commit and the most recently served good one, kept for rollback, and never touches `releases/state.json`) and `WEAVE_DEPLOY_TOKEN` (32+ characters; `POST /_supervisor/check` with that bearer token checks main at once, which the `Deploy dev` job calls). `WEAVE_TRACK=branch:` follows another branch, such as a `production` promotion branch, with the same `c` folders and deploy hook. Migrated draft PRs stay drafts until their own review finishes. 6. After every switch the supervisor watches the new release for `WEAVE_WATCH_MS` (default ten minutes). The serving worker exiting, three failed `/api/health` polls in a row, or a 5xx share of proxied responses at or above `WEAVE_WATCH_5XX_RATE` (default 0.25, over at least 20 responses) marks the release failed and swaps back, through the same health-checked path, to the kept rollback folder, or the image when none is kept. `lastSwap.rollback` names the trip and `/api/health` shows `supervisor.watch` with `until` and `ok`. A release is written good (`.weave-release-ok`) only when its watch passes, so a restart mid-watch boots the last proven release; a boot that fails falls back to the newest good `v` or `c` folder before the image. Migration rule: a release whose deferred migration wrote to any workspace in the data directory (`PRAGMA data_version` moved across the migrate step) is never rolled back automatically. It is written good at once, and a later trip logs an `ALERT` and sets `watch.alert` for a person to act on; old code never runs on a schema a newer release migrated. 7. Before it downloads a release the supervisor reads the free space on the releases volume. Under `WEAVE_RELEASE_FREE_MB` (default 128) it fetches nothing, records `lastSwap.ok: false` with the free space in the reason, and tries again on the next check; `/api/health` shows `supervisor.disk` with `freeMB` and `totalMB`. Grow the Railway volume when that number runs low. 8. `railway.json` sets `build.watchPatterns` to `/Dockerfile`, `/railway.json` and `/src/supervisor.js`. A push to main that touches none of them builds nothing on Railway, so the one-volume container restarts only for an image or supervisor change; workers move through the supervisor swap. ## Verification The `pr` workflow runs on every ready pull request. Its plan, `node scripts/ci-test.mjs pr-plan`, reuses the full-suite planner: every unit file in four shards, plus the browser tests the diff maps to. A diff that touches `public/app.js`, `public/style.css`, `src/engine.js`, `src/routes.js`, `src/commands.js` or a `test/lib/` file that a browser test imports, directly or through another helper, maps to every gating browser test, spread over eight shards as on main and weighed by file size (Feature #368). The plan finds those helpers by following each browser test's import statements, so there is no list to keep up to date. Any other diff keeps the name and reach selection, sharded at about twenty files each. A changed browser test runs itself. Any other changed file maps to browser tests named for its module (`public/chip-core.js` maps to `test/chip-*-browser.test.mjs`) or whose source cites its file name. The diff also maps by what it names: the plan reads the changed blocks with `git diff -W`, takes every dashed or slashed literal of six characters or more out of them (`sheet-grip`, `/api/tables/subtotals`), and adds each browser test whose source names one. A literal more than `REACH_SPREAD` browser tests name is too common to place a change, so it is dropped, and the reached tier stops at `REACH_CAP` files beyond the name matches, which holds the gate inside its twenty-minute job timeout. A change to any other public or server module therefore runs the journeys that name the DOM classes and routes it touches, which file names alone never reach (Issue #1075). A fixed smoke set (`SMOKE` in `scripts/ci-test.mjs`) always runs, so a diff nothing maps to still boots the app in a browser. `PR gate` succeeds only when every shard succeeded and `pr-aggregate` finds executed evidence for every planned file at the exact commit. A newer push to the same pull request cancels the running one. Every browser job installs through `node scripts/ci/install-browsers.mjs `, the one copy the `pr`, `tests`, `budgets` and dev soak workflows share. It writes an apt drop-in (twenty-second acquire timeout, three retries, a 300-second `DPkg::Lock::Timeout` so an attempt outlasts the runner's own boot-time `apt-get`, Issues #1230, #1141, #1107), installs the browsers from the `~/.cache/ms-playwright` cache, and then asks Playwright's own `install-deps --dry-run` whether every system package is present. When one is missing it installs the `.deb` files kept in `~/.cache/weave-apt` with one `dpkg -i`, which never reaches an Ubuntu mirror and waits up to 300 seconds for a dpkg lock the runner's own `apt-get` holds. `apt-get install --no-download` of the same files is the wrong tool: it re-resolved them against the image's package lists and asked to fetch (PR run 42654813034). That cache is keyed on the runner image (`ImageOS` and `ImageVersion`), the pinned `PLAYWRIGHT_VERSION` and the browser set, so a new runner image never installs another image's packages. Only when the cache is empty, or leaves a dependency missing, does the script run `playwright install --with-deps` against the mirror: three attempts with `dpkg --configure -a` between them, reading its result, after which it copies the downloaded packages into the cache for the next run. Before Issue #1438 every browser job took that mirror path, and a slow mirror hour timed the step out at 14 minutes on main 13 times between 2026-11-05 and 2026-11-09. Every path ends on the `install-deps --dry-run` check, so a browser or system package that is still absent fails the step loudly, and the log line says no test ran. The only deadline is the step's own `timeout-minutes`, 12 on the `pr` workflow and 14 elsewhere; an inner per-attempt `timeout` was removed on 2026-11-04 because it killed attempt one mid-download (Issue #1347). `test/ci-install-browsers.test.mjs` and `test/ci-test.test.mjs` hold these rules. When a shard dies before any test records a result, the sheriff has no file to name, so it opens or appends to a row named after the shard. That line names the step the job died in, so an install timeout and a shard that crashed in its test step no longer read the same, and an install failure never adds a line to a per-file flake row (Issue #1353). The step name comes off the `steps` the jobs API already returns with each job, so it costs no extra call. The `security scan` check downloads gitleaks, actionlint and hadolint from the GitHub release CDN, and the release workflow downloads the MCP publisher the same way. Each download retries across 120 seconds (`--retry 6 --retry-delay 20`), because a four-attempt budget spread over six seconds fit entirely inside one 504 blip on 2026-11-09 and, since the check is required, took every pull request in the repository down with it (Issue #1490). The three scanner downloads run before the first checksum under an ERR trap, so a scanner that cannot be fetched is annotated as a network failure instead of reading as a finding in the code, and a bad SHA256 pin is still reported as itself. `test/security/workflows.test.mjs` holds that floor for every release-asset download in every workflow. The `tests` workflow runs the full inventory on every main push. Its `plan` job downloads the `ci-results` artifacts of the most recent successful main run and weights each test file by the `duration_ms` the reporter recorded for it, so the eight browser shards balance on measured runtime and all ten shards start at once (`max-parallel: 10`). Four browser shards ran 15 to 19 minutes each by 2026-11-08 and six of seven retried main runs that week were a shard killed at the test manager's 20-minute job timeout; eight bring each to about nine (Issue #1351); a file with no recorded duration keeps the byte-size weight, and a run with no usable history (expired artifacts, a first run, a download failure) plans entirely by byte size. Byte size is anti-correlated with runtime for the worst offenders, because the slowest browser files are small files that loop: `test/render-budget-browser.test.mjs` is 1,682 bytes and ran 164 seconds in run 41959983156, where `test/setup-flows-browser.test.mjs` is 52,749 bytes and ran 119. On that run's inventory the byte-weighted plan gave the browser shards 1,171, 1,107, 1,939 and 1,622 seconds of work; the duration-weighted plan gives them 1,460 each (Issue #966). The shards and the `CI gate` rebuild the same manifest from the same weights: the plan job publishes what it downloaded as the run's `plan-history` artifact and both take their copy from there, so a divergence can only come from a failed download, and `aggregate` then refuses the shard whose `manifest` digest does not match rather than passing a plan nobody ran. Every step that reaches for the durations tolerates their absence in the shell rather than through `continue-on-error`, which a killed step never reaches. Its final `CI gate` succeeds only after every required job succeeds, and a release can only publish a main commit whose `CI gate` succeeded. Browser engines are installed on the browser lanes only, in both workflows: a unit shard opens no browser, and the two unit shards of run 39439094335 spent 46 and 37 seconds downloading Chromium and WebKit they never used (Issue #967). `test/ci-test.test.mjs` holds the lane condition and refuses a unit-lane test that reaches a browser at load. A missing browser or skipped required browser suite fails the gate. Parallel CI jobs have isolated runners and test data. Superseded branch runs are canceled. Each main push holds its own concurrency lane, keyed by the commit under test. A shared lane keeps one pending run at a time, so the next merge cancelled the gate queued behind it: eight merges in the half hour from 18:32Z on 2026-10-25 left nine consecutive main gates cancelled and the deploy watcher two hours behind the tip (Issue #1181). A merge rate above the gate's nineteen-minute runtime now costs parallel runs instead of a stalled deploy. Local runs are for debugging: `npm test -- --targeted ` reproduces a red shard, and CI owns verification. Never invoke bare `node --test`; empty selections must fail before Node starts. The shared test manager serializes local browser work across worktrees. Use `npm test -- --status` to inspect the queue and cancel only your own job. Browser assertions use `styleOf`, `settled` or `atRest` from `test/lib/browser.mjs`, never fixed sleeps. `settled` reads the animations running right now, so it answers at once when the interaction has cleared an inline transform and the transition it starts has not been resolved yet. `atRest(locator)` is the wait for that: it holds until the element has no running animation, its transform is identity and its box has stayed put for a frame (Issue #901). `WEAVE_CPU_THROTTLE=4` helps reproduce gate-only browser failures. Retries remain visible in test output; repeated flakiness gets a tracker issue. A test that needs a scratch directory calls `tempDir(prefix)` from `test/lib/temp.mjs`, which removes every directory it opened when the process exits. `test/temp-dirs.test.mjs` refuses a bare `mkdtempSync(join(tmpdir(), ...))` anywhere under `test/`, because a suite run outside the manager, such as a bare `node --test` or a browser suite that only takes a browser lease, keeps the caller's own `TMPDIR` and leaves those directories on the disk (Issue #1083). Each lane is spawned with `WEAVE_KEYSTORE` and `WEAVE_DATA` pinned into that lane's own temp directory, so a test that builds a `Weave` without a keystore path or runs the CLI without `--data` lands in the sandbox instead of the developer's `~/.weave` (Issue #949). `test/test-runner.test.mjs` fails when a lane resolves either default under the real home directory. ### Sheriff `.github/workflows/sheriff.yml` owns a red main (Feature #320). A failed main push `tests` run gets its failed jobs rerun by `scripts/sheriff.mjs`. Only the rerun's `CI gate` conclusion decides guilt, and only `success` and `failure` count: a rerun that ends `cancelled`, `timed_out` or `skipped`, or whose `CI gate` job never ran, carries no verdict (Issue #1139). The sheriff reruns the failed jobs once more and, with still no verdict, skips the run and leaves the next red main run to judge that commit. The `tests` concurrency group cancels a rerun whenever a newer main commit pushes, and the cancelled rerun used to read as a second red. A green rerun is a flake: it appends a line to the Issue row `Error: flakes on main`, creating it if none is open, and stops. A second red finds the commits since the last main run whose `CI gate` succeeded and bisects them by dispatching `tests` with its `ref` input, at most four runs. Completed runs for a commit are reused for free. A probe that goes red on a test file other than the one holding main red says nothing about this red, so the sheriff escalates with both file lists instead of narrowing to that commit. It checks once more that main has not gone green on its own, then opens a `sheriff/revert-` PR with squash auto-merge, comments on the original PR, files `Error: fails on main after PR #` with the run links, and writes one alert line to the job summary and `SHERIFF_ALERT_URL`. Then it reads the `Issue #` and `Feature #` references off each reverted commit's subject and sets each of those rows back from `Fixed` to `Open`, or from `Shipped` to `Building`, with a dated line naming the revert PR, so the reland is queued instead of the row reading done while its fix is off main. A row in any other state is left alone. The test it names is a file still red after the in-run retry; a file whose shard ended before its retry answered is listed under its own heading in the evidence instead. When the run reached no retry verdict on any file and at least one file was red without one, as a shard killed by its own timeout leaves it, the sheriff convicts nothing: it escalates with that list and logs a `no-verdict` line on each of those files' flake rows, because a shard that ended before its retry answered says nothing about the commits under it (Issue #1322). A timed-out shard can also leave no red file at all, when the manager killed it before any of its files answered: `scripts/ci-test.mjs` records `timedOut` in a shard's evidence when the manager exits 124, and a red held only by timed-out shards, with no file red and none unverdicted, escalates the same way under `Error: tests shards time out on main at `, since the gate conclusion alone names no culprit. Past the cap it reverts what is still suspect. A revert whose range took no bisect probe, the single-commit range among them, says so in its subject, so main's history shows how much evidence the revert carried. It never reverts a revert, never acts on a culprit that already has a revert branch, never pushes to main and never forces a push. Replay a run without writing anything: `gh workflow run sheriff -f run_id= -f dry_run=true`, or locally `node scripts/sheriff.mjs --dry-run`. Secrets: `SHERIFF_TOKEN` (fine-grained token with contents, pull requests, actions and workflows write, so the revert PR starts `Dev gate` and its merge starts `tests`; without it the PR opens unarmed and the sheriff job fails), `WEAVE_TRACKER_TOKEN` (a dedicated editor bearer for the canonical tracker; a missing token fails incident and flake recording) and the optional `SHERIFF_ALERT_URL`, which receives the alert line as a POST body. When `SHERIFF_ALERT_TOKEN` is also set, the POST carries `Authorization: Bearer `; the `weave-alerts` Worker refuses a request without it. The repository's Actions setting must allow GitHub Actions to create pull requests when using the built-in token. Keep the default workflow permission read-only; the sheriff job declares its required write permissions. Scope `SHERIFF_TOKEN` to this repository only. An escalation or unarmed revert fails the sheriff job after writing its evidence. Read-only dry runs still succeed. ### Dev smoke and soak `.github/workflows/dev-soak.yml` runs after the `dev` workflow's `Deploy dev` job confirms a main commit is serving on dev.weave.grunion.ai. It runs `scripts/dev-soak.mjs`: a Playwright smoke (load the app, open the `/w/weave` workspace, open a table, open a document) every three minutes for fifteen minutes, and dev's `/api/health` every thirty seconds. A failed smoke runs again at once; a second failure in a row fails the soak. The soak passes when the supervisor's `watch` for `c` reports `ok: true`, no rollback happened, the worker's pid never changed and dev answered no 5xx. It then posts the commit status `dev-soak` (success) on that SHA; promotion reads that context. A failure posts `dev-soak` failure linking the run and sends one line to `SHERIFF_ALERT_URL` and the job summary. A new main merge never ends or restarts a window, because the soak exists to review a batch. The job's concurrency keeps one running soak and one pending run, and a newer pending run replaces the older one (it shows as cancelled), so merges queue and the next window takes the newest commit. When dev moves to a newer commit and main's head differs from the soak's SHA, the run keeps its clock and its smoke schedule to the end of the window. Main is linear, so every later commit dev serves contains the soak's SHA. From then on the pid check and the SHA's own watch no longer apply. Each poll instead checks whatever release dev serves now for a rollback, a watch alert or `watch.ok === false`, and a release with no watch yet is fine. The run passes when the window has elapsed, at least one smoke ran, 5xx stayed within `DEV_SOAK_MAX_5XX` and none of those checks tripped, and it never waits for a watch. Its `dev-soak` success names the commit dev moved to. Dev serving another commit while main's head still equals the SHA stays a failure. A failure after a move cannot be pinned on the SHA, so it posts `dev-soak` pending described as superseded, with no alert, and promotion skips it. The queued soak on the newest commit catches a real break and alerts. A smoke, 5xx or health failure polls health once more first, so a swap the last poll missed counts as a move. A rollback of a later release counts as a move even when dev lands back on the soak's SHA. A rollback of the SHA itself is a failure. With the `DEV_SMOKE_TOKEN` secret (an editor `wv_` token on dev) the smoke also creates a row through the API, sees it in the grid, and hard-deletes it, in the workspace named by the `DEV_SMOKE_WORKSPACE` variable (default `/w/smoke`, which needs at least one table). The token goes only to that workspace, since a token from another workspace answers 401 even on a public page. Without it the smoke covers the anonymous read surface only. Run one smoke locally with `node scripts/dev-soak.mjs smoke`, or soak a commit dev serves with `gh workflow run dev-soak -f sha=`. ## Releasing A release starts with a notes-bearing `Development/Release` row in the canonical weave workspace on `:4400`: name `v`, Date, Commit, Fixes and Ships relations, and Description. Preserve this tracker contract before bumping `package.json`. ```bash node scripts/export-development.mjs node scripts/changelog-fold.mjs node scripts/architecture.mjs ``` The fold also stamps the version into `server.json`. After the `publish` job tags the release, `release.yml` pushes `ghcr.io/grunion-ai/weave:` and `:latest`, then publishes `server.json` to the Official MCP Registry with a pinned `mcp-publisher` signed in through GitHub OIDC. Once the version PR is merged, write the merged commit onto the Release row: ```bash node scripts/release-commit.mjs --from https://weave.grunion.ai/w/weave \ --token-env WEAVE_TRACKER_TOKEN --release v --sha ``` That step appends one line to the notes rather than replacing the document, then reads the row back and refuses when the notes came back empty or without the commit. A release whose notes are lost by its own run is caught by that run (Issue #1329). An older row that has lost its notes stops nothing: the export names it, keeps the notes `docs/development.json` already carries, and carries on. ### Canonical tracker The `weave` docs workspace on production, https://weave.grunion.ai/w/weave, is the one canonical tracker for Development/Issue, Feature and Release rows. Every other server holds a read copy that boot syncs from `docs/development.json`. - The canonical server sets `WEAVE_TRACKER_CANONICAL=1`. Boot then skips the manifest sync and logs one line saying so, so edits made on the tracker are never overwritten by an older build's manifest. A canonical server never forwards bug reports. - Every other Grunion server sets `WEAVE_BUG_FORWARD_URL` to the workspace base (`https://weave.grunion.ai/w/weave`) and `WEAVE_BUG_FORWARD_TOKEN` to a bearer token for it. `POST /api/bug-report` then posts the report to `/api/bug-report` with the instance's origin (`WEAVE_ORIGIN`, or the request host) as `instance`, which the canonical report shows as its Instance row. A forwarded report answers 201 with the canonical row and `forwardedTo`. A network error, a 10 s timeout or a non-2xx answer files the report locally as before, notes "Forward to failed: " in it, and answers with `forward: "failed"` and `forwardError`. With either variable unset nothing is forwarded. - Export reads the canonical tracker over HTTP: `node scripts/export-development.mjs --from https://weave.grunion.ai/w/weave --token-env WEAVE_TRACKER_TOKEN`. The file mode (`node scripts/export-development.mjs [path/to/weave.db]`) still works, and `--out ` writes somewhere other than `docs/development.json`. Land the version, exported manifest, folded changelog and architecture map in one PR. Between releases every change adds its own `changelog.d/-.md`; only a version bump edits CHANGELOG.md. The fold sorts and deletes fragments. A refactor or documentation change without a release bullet carries `No-changelog: ` in its commit. The changelog and development-sync tests enforce these contracts. Releases publish automatically after successful same-repository main push `tests` runs and never move production. Retry publication by dispatching the `release` workflow with that tests run ID. Its read-only guard validates the same-repository event, run ID and completed `CI gate`. Its publishing job checks out the exact tested SHA and checks GitHub main again; a superseded run skips publication. The publisher validates the version, matching tracker manifest and nonempty `## v` changelog section before creating an immutable `v` tag and GitHub Release. Existing published ancestor versions stay unchanged. A partial publication resumes only if its tag names the tested commit; a conflicting tag fails and is never moved. To revert a release, land a tested revert PR and cut a new version. Historical recovery uses `gh workflow run release -f run_id= -f historical=true`. It requires a successful main push tests run for a commit still on main history. It publishes that exact commit, leaves GitHub Latest and the image `:latest` alone, and never moves an existing tag. Recover versions oldest first. A later ordinary run uses the existing immutable tag for image and registry retries. Automatic runs skip image and registry publication for an already published ancestor version. Confirm the publication with `gh release list -R grunion-ai/weave --limit 1`. ### Production promotion Production follows the `production` branch. The hosted supervisor at https://weave.grunion.ai runs with `WEAVE_TRACK=branch:production` and a `WEAVE_DEPLOY_TOKEN`, so it serves `releases/c/` folders and swaps, watches and rolls back exactly as dev does (Daily flow, steps 5 and 6). `.github/workflows/promote.yml` (Feature #322) moves the branch. It runs when a `tests` or `dev-soak` run on main completes with success, so a commit promotes as soon as the later of its two checks passes (Issues #1535, #965). The chain `dev` (push) → `dev-soak` → `promote` is three `workflow_run` levels, the most GitHub fires. A completion run acts at once, like `workflow_dispatch`, and reads main itself instead of the event's SHA. The hourly schedule stays as the recovery path: a scheduled run acts when the newest `production` deployment is at least the repository variable `PROMOTION_EVERY_HOURS` (default 2) less half an hour old. GitHub delivers scheduled runs late or drops them under load, so that gate counts from the last promotion and never from the clock hour (Issue #1493). When the pick was committed to main more than `PROMOTION_EVERY_HOURS` ago, the promote job writes one alert line naming the lag before it moves production, since the completion triggers should have taken it sooner. The `promotion` concurrency group keeps one run at a time. `workflow_dispatch` acts at once, and its `dry_run` input only prints the pick. `scripts/promote.mjs select` picks the newest main commit that has a push `tests` run whose `CI gate` succeeded and a `dev-soak` commit status of success, and that descends from the `production` head. With none it exits quietly. The `promote` job then, in the `production` environment: 1. pushes `:refs/heads/production` through the promotion deploy key (`PROMOTION_DEPLOY_KEY`), a fast-forward that is never forced; 2. calls `POST /_supervisor/check` with `PROD_DEPLOY_TOKEN`; 3. waits up to 25 minutes for `/api/health` to show `supervisor.commit` at that SHA, `lastSwap.ok` true and `supervisor.watch` for `c` with `ok` true. A failed watch or a rollback fails the run, files the Issue row `Error: production rolled back the promotion of `, writes one alert line and opens a `promote/revert-` PR on main for the promoted range (armed when `SHERIFF_TOKEN` is set). `production` is never rewound: it stays on the failed commit while prod serves its rollback, and the next promotion waits for a main commit that reverts it. A timeout files and alerts without a revert. A promotion that passes while `/api/health` reads `auth.ok: false` stays promoted and writes one alert line. A broken sign-in comes from provider configuration, such as a rotated client secret, so holding the promotion would only hold back a fix. Set the repository variable `PROMOTION_PAUSED=true` to stop promotion; delete it or set it to anything else to resume. Two rulesets guard the branch: nobody deletes it or pushes a non-fast-forward, and only the promotion deploy key updates it. Both secrets live in the `production` environment, which has no required reviewer and admits main only. After a promotion passes its watch, the job runs `node scripts/exposure.mjs` (Issue #879). It reads every workspace name from `GET /_supervisor/workspaces`, which the worker answers only to `Bearer `, then asks each one for `GET /api/tables` with no credentials, by `/w//` and by `.weave.grunion.ai`. Any 200 fails the run, unless the workspace is named in the repository variable `PUBLIC_WORKSPACES` (comma-separated). Run it by hand with `WEAVE_DEPLOY_TOKEN` set to the production token. Check a promotion: ```bash gh run list -R grunion-ai/weave --workflow promote --limit 3 gh api repos/grunion-ai/weave/branches/production --jq .commit.sha curl -fsS https://weave.grunion.ai/api/health ``` Never hand-deploy Railway after a routine landing. Container redeployment is reserved for Node, Dockerfile or supervisor changes. ## Architecture map `docs/architecture/` holds an interactive map of the runtime rendered by [archify](https://github.com/tt-a1i/archify): `weave.architecture.json` is the source, `weave.architecture.html` the rendered page, and every node links to its code at the commit named in `meta.repository.revision`. `test/architecture-map.test.mjs` goes red when a cited file moves or is deleted, when a new `src/` or `bin/` module is not named on the map, or when the HTML no longer matches the JSON. The change that trips it updates the JSON and runs `node scripts/architecture.mjs`, which re-pins HEAD and re-renders. Release step 5 re-pins it too, so every tagged version carries a map of the code it shipped. archify is a dev tool, found at `$ARCHIFY` or `~/.claude/skills/archify/bin/archify.mjs`; weave never depends on it. ## Retired Gerrit queue Gerrit and its review worker are retired from the landing path. Preserve their repositories and open-change exports until migrated draft PRs are reconciled. Do not run the old mirror or release publisher alongside GitHub delivery. Retained Gerrit refs are historical recovery material, not an authority for new landings. ## Shared test manager Use `npm test` for full verification and pass several filenames to batch related regression checks. Every CLI request goes to one per-user manager shared across worktrees. Selections of unit suites only run at once and in parallel, within the admission limits below; a selection with any browser suite waits for the one browser slot, which runs one job at a time. Each job runs `scripts/test.mjs`'s two lanes (unit suites at node's default, browser suites at half the cores). A persistent browser server supplies fresh Playwright connections and contexts to each browser suite; closing one suite cannot close another suite's contexts. Direct browser-harness callers also lease the slot. A suite that launches a browser imports `test/lib/browser.mjs` or ends in `-browser.test.mjs`; the manager refuses a browser to a unit-only job. `npm test -- --status` lists the slot holder under `active`, the unit-only jobs under `unit` and waiting jobs, with their reason, under `queued`. Use the CLI for predictable whole-job admission. | Operation | Command | | --- | --- | | Batch targeted regressions | `npm test -- --targeted test/formula.test.mjs test/service.test.mjs` | | Inspect affected selection | `npm test -- --affected --base=HEAD --plan` | | Run affected selection | `npm test -- --affected --base=HEAD` | | Landing check | GitHub Actions `dev` runs `Dev gate` on ready pull requests | | Full verification | GitHub Actions `tests` runs every suite on main; `CI gate` reports the result | | Queue and browser status | `npm test -- --status` | | Cancel one queued or active job | `npm test -- --cancel=` | | Stop owned jobs and browser | `npm test -- --stop` | | Bound one job, milliseconds | `npm test -- --timeout=120000 test/formula.test.mjs` | Affected selection includes staged, unstaged and untracked files relative to the base. Only changed tests narrow it; source, UI, configuration and unknown paths require the full suite. The printed plan explains the decision. Full landing verification remains mandatory. An empty explicit selection fails. File lists must name existing files; use `--test-name-pattern='pattern'` or its separate-value form to filter test names. The manager hashes the paths a selection reaches at enqueue, start and finish: the selected tests, the code and config paths the sheriff counts as reaching (`src/`, `public/`, `bin/`, `test/`, `scripts/`, `agents/`, `tools/`, the workflows, the Dockerfile and root config files), and any other top-level path that a selected test, a module it loads or a script it names mentions. A changed file invalidates a result only when it sits on one of those paths. Results are never cached, and duplicate requests remain separate jobs. Keep those paths unchanged while tests run. An agent may assess scope or propose redundant-test removal, but cannot omit required verification. Existing `run()` imports are the low-level synchronous fixture API; agents submit work through the CLI. The browser recycles between jobs after 20 browser jobs or 15 minutes, after a browser-slot job fails or is cancelled, and when resource pressure blocks admission while the slot is free. The manager exits after five idle minutes. It starts automatically on the next request. Browser versions and browser kinds use separate lifetimes. `WEAVE_BROWSER=webkit` uses the same queue. No npm dependency or launchd job is installed. Admission waits below 10 GB available disk, below 10% available memory, or above a one-minute load average of twice the CPU count. Status and waiting messages name the reason. An admitted job times out after one hour by default, the gate's 3600 s cap, which is also the maximum `--timeout`. Queue waiting expires after `WEAVE_TEST_QUEUE_WAIT_MS` (default 30 minutes). A job that is never admitted, because its wait ran out or the manager stopped first, prints `# TEST MANAGER NOT ADMITTED: ` and exits 75: its tests did not run, so a gate skips its vote rather than rejecting. A disconnect cancels its job. A responsive supervisor reaps the owned process group if the manager dies. Job temp directories are removed after process cleanup; stale manager-owned temp directories older than 24 hours are pruned on startup. Other agents' files and processes are untouched. `WEAVE_TEST_MIN_FREE_GB`, `WEAVE_TEST_MIN_MEMORY_PERCENT`, `WEAVE_TEST_MAX_LOAD` and `WEAVE_TEST_QUEUE_WAIT_MS` override admission for a deliberately sized CI machine, or for a caller that is already serial, such as a hosted CI runner. A raw `node --test` browser suite sends them with its lease. Keep the defaults for ad hoc runs on this shared workstation. One process holds one browser lease however many times its suites call `launch()`, and releases it when the last connection closes. `WEAVE_TEST_MANAGER_DIR` selects a private socket directory for isolated manager integration tests; ordinary jobs use the shared default, which is keyed on a hash of `scripts/test-manager.mjs`. Worktrees on the same manager code share one queue; a worktree with changed manager code gets its own manager, and the old one exits after five idle minutes. `--stop` cancels active work, so inspect `--status` first. ## Theme loops A browser case runs in both themes only when it reads paint: a computed colour, a contrast, a screenshot, a border or shadow, an icon or image. A theme cannot change a geometry, a count, a class name or a text value, so a case that reads only those runs once, in light. `test/theme-loop-guard.test.mjs` fails a `for (const theme of ['light', 'dark'])` loop whose body reads no paint (Issue #667). ## State catalog `test/lib/states.mjs` is the one list of app states the runtime probes walk (Issue #1119). A state names a route, a layout (`table`, `list`, `board`), an open state (`journey`, `rest`, `overlay`, `hover`, `drag`), the seeds it renders on, its cells and its themes. `PROBES` says which seed, open states, cells and themes each probe measures, and `plan(probe)` is the list it walks: the cut-off sweep, the flicker gate, the budgets, the a11y gate and the touch gate all read it. `test/lib/journeys.mjs` and `test/lib/overlays.mjs` are filtered views of it. A state that names no seed renders on every seed, so a surface added that way falls under every probe at once; the board and list views entered first. Every seed also carries the kitchen sink: a Lab/Sample table with all 22 field types and adversarial values (a 112-character unbroken word as name, text, key, URL, email and file name, an eleven-digit currency, a 55-character option, a 48-character workflow state, a century-long date range, and a one-letter name beside them). The shared states `sink-table`, `sink-list`, `sink-board` and `sink-row` walk it on the table, list and board layouts and the record page, so every probe sees every field type. A state that fails a probe it newly joins carries a `hold` naming the Issue rows that track the failure, keyed by probe (`touch`), probe and cell (`cutoff|desktop`), or probe, cell and platform (`touch|phone|darwin`), which holds that platform alone while the others read the plain key. The touch gate's offender ledger `test/touch-held.json` carries the same platform last in its own key, so one platform's font metrics can be held without reddening the other, as `test/cutoff-baseline.json` already does per platform (Issue #1542). A held state stays out of that gate and is still walked: the gate asserts it still shows what its rows track and prints the counts. **Fixing one:** fix the rows, watch the held test fail because the state is clean, and drop the hold in the same change so the gate walks it. A hold never stands in for a baseline: `test/cutoff-baseline.json` and the touch gate's zero tolerance are unchanged. ## Quality scorecard `scripts/quality-scorecard.mjs` prints one JSON row of quality numbers for the current ISO week, computed from the repo at HEAD and the last 7 days of main (Issue #1120). The nightly flicker routine writes it to the `Development/Scorecard` table on the canonical tracker, one row per week, so the trend sits beside the Issue rows it explains. ```bash node scripts/quality-scorecard.mjs # this week's row node scripts/quality-scorecard.mjs --issues issues.json # plus the probe answers on those Issue rows ``` | Field | Read from | Direction | | --- | --- | --- | | UI fixes, Class-wide fixes, Generalization rate | Commits naming an Issue that touch `public/`. Class-wide means the commit touched a baseline or budget file (the byte budget excepted), a probe module in `test/lib/` or a static source gate | Rate rises | | Touch, Cut-off and Alignment repeats | Subjects of those UI fixes | Falls | | Reverts | `Revert "…"` and sheriff reverts on main | Falls | | Coverage per probe | States each probe walks over every state in `test/lib/states.mjs` | Rises | | Field types seeded | Types the catalog seeds create, out of 22 | Rises | | Width bands covered | Probe viewports across the bands cut at 480, 600, 700 and 900 px, out of 5 | Rises | | Cut-off instances and fingerprints, A11y entries, Touch holds, Touch baseline entries, Held states | `test/cutoff-baseline.json` (linux), `test/a11y-baseline.json`, the catalog's holds | Falls | | Spacing and Z-index on tokens | Declarations in `public/*.css` whose value reads a `var(--…)`, comments stripped | Rises | | Probe answers, Probe answers none | The bug report's "Which probe would have caught this?" section on Issue rows updated in the window | None falls | The class-wide test reads file paths, and the repeat classes read subject keywords, so small counts are approximate. Over the 27 days to 24 Oct the script counts 18 class-wide fixes of 263, where the hand audit counted 23 of 247. ## Flicker loop A flicker is something the reader saw that should not have been there: a node painted for a beat and gone, a list painted empty and refilled, a class that flipped and flipped back, content that jumped with no input, a screencast frame that differs from both neighbours while they match. `test/lib/flicker.mjs` is the probe; it walks the journeys and the rest states the state catalog plans for it (load, open a table, scroll, edit a cell, open a row, switch view, type in a document, trash and undo, flip the theme, the phone journeys, and loading the board and list views). ```bash node scripts/flicker-sweep.mjs # 3 runs, keeps what 2 saw node scripts/flicker-sweep.mjs --journey open-row --runs 1 --min 1 # reproduce one ``` The harness routine `weave-flicker` runs the sweep nightly against GitHub main and files each confirmed finding as an Issue row carrying its fingerprint (`journey|kind|selector`) and the frames around it. The overnight fixer picks those rows up like any other. **Fixing one:** reproduce with the sweep, fix, and add the fingerprint to `test/flicker-fixed.json` with the Issue number in the same change. `test/flicker-gate-browser.test.mjs` walks every journey in the gate and fails when a listed fingerprint comes back, so a fixed flicker stays fixed. A journey whose selectors rot fails the gate too. The gate reads the DOM probe only, so it reads the kinds in `GATED` (`transient`, `blank`, `revert`, `shift`, `cut`). A `frame` finding comes from the sweep's screencast, which the gate does not run: taking it would put the gate's own paint timing into the result. `fixedFault` refuses a `frame`, `jank` or `rerender` key in `test/flicker-fixed.json` for that reason (`test/flicker-core.test.mjs`, Issue #702), because such an entry would sit there unchecked. Hold a frame finding with a `frameFlashes` case in `test/flicker-core.test.mjs` built from the frames on the Issue row, and mark the row Fixed naming that case. ## Click budget Every journey in `test/lib/journeys.mjs` is an action a person takes, and `walk` counts the clicks it takes: trusted primary pointer presses (mouse clicks and taps) during the run phase. Setup clicks, keys and scripted `element.click()` calls count nothing. `test/click-budget.json` holds the budget per `journey|cell`. `test/click-budget-browser.test.mjs` walks every journey on every PR (it is in `SMOKE`) and logs `clicks budget ` as a test diagnostic. Budgets only ratchet down: - a count above its budget fails: remove the extra click; - a count below its budget fails until the same change lowers the budget, so the saving holds; - a new journey adds its own `journey|cell` entry with its measured count. Raising a budget is the one way to merge an action that takes more clicks. `test/click-budget.test.mjs` compares the file with `HEAD^1` and refuses a raised number unless the same change writes a new reason under `raised[""]`. The reason names the Issue or Feature that needs the click. ## Vitals budget `test/vitals-budget-browser.test.mjs` loads home (`#/`) and a table (`#/table/`) cold, once to warm up and then seven times, on desktop and on the phone, and reads LCP, CLS and INP from PerformanceObserver (`test/lib/vitals.mjs`). After the page is ready it makes one real interaction for INP: desktop opens the Task table from the sidebar or a row's dock, the phone opens the menu or taps a row. CLS is the largest session window of shifts without recent input. The medians are judged against `test/vitals-budget.json` (`vitals[""]`) and logged as `vitals runs <...> budget `. The phone cell runs Chromium with the iPhone 15 profile, because WebKit reports no `layout-shift` entries. Budgets hold the Linux runner's medians with headroom; a raise needs a new reason under `raised[...]`, checked against `HEAD^1` by `test/vitals-budget.test.mjs`. ### Trend lane The vitals and feedback suites (`TREND` in `scripts/ci-test.mjs`) time pages in milliseconds on shared GitHub runners, where the same code read an open-table INP of 88 to 216 ms (Issue #1215). As gates they turned main red on runner noise, and the sheriff reverted at least three PRs for it in the week to 2026-11-01 (#505, #513 and #534). They now sit outside every gating plan: the `pr` and `tests` workflows never plan them, so the sheriff, release and promotion never read them. `.github/workflows/budgets.yml` runs them on every main push through `scripts/ci-trend.mjs`. A breach fails that run, which leaves a visible red on the commit; the third consecutive failed run (cancelled runs neither count nor break the streak, `SUSTAINED`) writes an error and posts one alert line to `SHERIFF_ALERT_URL`, since a breach that repeats is a regression and one that does not is the runner. The budget files, their `raised[...]` reasons and the `HEAD^1` checks in `test/vitals-budget.test.mjs` and `test/feedback-budget.test.mjs` still gate every PR. The click and render budgets count events, not milliseconds, and stay on the gate. ## Feedback budget An action answers on screen before the server does. `test/feedback-budget-browser.test.mjs` walks every journey three times with each `/api/` request held 1000 ms by a Playwright route, and times each trusted press or commit key (Enter, Tab, Escape) to the first animation frame after a visible DOM change. A skeleton, a loader or `aria-busy` is not an answer. The journey's time is its slowest action; the gate reads the median of the three walks against `test/feedback-budget.json` (`ms[""]`) and logs `feedback runs budget acts `. A fourth walk runs against the unheld server and refuses any loading state shown for an action that settles inside the skeleton delay: a skeleton or loader visible sooner than `SKELETON_SHOW_AFTER_MS` after an action, or `aria-busy` that sets and clears inside it. `busy[""]` lets a journey off that check only while it names the Issue that will fix it. Budgets hold the Linux runner's times with headroom, since a timing gate cannot ratchet down on every run the way clicks do. A time over its budget fails the trend lane (below), not the gate. Raising a budget needs a new reason under `raised[""]`, checked against `HEAD^1` by `test/feedback-budget.test.mjs`, the same rule as the click budget. Journeys that wait on the server today carry their measured time and the `Slow:` Issue that tracks them; lower the budget in the change that fixes one. ## Render budget `test/render-budget-browser.test.mjs` walks every journey three times with the flicker probe and counts its renders: animation frames in which the app wrote to the DOM under `` (child lists, `class`, `hidden` or `style`). The median of three walks is judged against `test/render-budget.json` (`renders[""]`) and logged as `renders runs budget `. Budgets hold the Linux runner's largest median plus a quarter and one frame, since network timing splits writes across frames from run to run. A count over its budget fails. Batch the writes, or raise the budget with a new reason under `raised[""]`, which `test/render-budget.test.mjs` checks against `HEAD^1`. ## Cut-off loop Weave never shows cut-off text. Cut-off text is glyphs sliced by a box edge: a line cut through its middle, a letter cut in half, a chip cut at its cell's edge. An ellipsis is not a cut-off (Kyle, 2026-11-06): a `text-overflow: ellipsis` box or a `-webkit-line-clamp` box that ends on a whole line shows every glyph it draws, and its three dots say there is more. `test/lib/cutoff.mjs` is the probe: a page function that reports text cut by an overflow hidden or clip box, an ellipsis or line-clamp box that an ancestor box cuts, and a line-clamp box whose height falls short of its whole lines (all `clipped`), and an input or textarea whose value or placeholder runs past its box (`field-value`, `field-placeholder`). Text inside an overflow auto or scroll container, visually hidden text and invisible elements are not cut-offs. The current breadcrumb's name is skipped by design (Issue #1159): the title row under every crumb row shows the full name, so the crumb keeps a fixed share and gives way before any other crumb shrinks. A finding's fingerprint is its kind plus its selector with every number folded to `#`, and `|phone` for the phone cell. ```bash node scripts/cutoff-sweep.mjs # both cells, writes cutoff-out/findings.json node scripts/cutoff-sweep.mjs --cell phone --overlay field-editor,new-field # reproduce one node scripts/cutoff-sweep.mjs --lower # after a fix: lower this platform's baseline ``` The sweep walks the journeys and rest states the state catalog plans for it and opens every dialog and popover listed in `test/lib/overlays.mjs`, at desktop 1440x900 in Chromium and phone 390x844 in WebKit, on the messy seed. `test/cutoff-gate-browser.test.mjs` runs the same sweep in the gate against `test/cutoff-baseline.json`, which holds today's counts per platform, since system fonts differ between macOS and the Linux CI runner. The baseline is a ceiling. The gate fails on a fingerprint the baseline does not hold, or more instances than it holds. A count under the ceiling is a stale baseline: the gate prints it as `stale:` and passes, since the same commit measured one breadcrumb fingerprint 5, 7 and 8 times on loaded runners (Issue #1103). `test/cutoff-core.test.mjs` refuses a baseline that grows past its committed version or past the base a pull request merges into, so the ceiling only ever comes down. **Fixing one:** fix the cut-off, run the sweep until the lower count holds, run it once with `--lower`, and commit the lowered counts in the same change. A red gate prints the Linux counts it measured; copy them into the `linux` section. ### Line endings `DESIGN.md` under Text gives every text one role. The same page function checks line endings when it is called as `probe({ root, endings: true })`, and reports two kinds with no baseline, since each is a defect on its first appearance (Issue #1306): | Kind | What it found | | --- | --- | | `sliced-line` | A line partly inside and partly outside the box that clips it by more than 2 px, or a line clamp shorter than its whole lines | | `lone-word` | A single word on the last line of a paragraph, when the word before it would fit beside it | ```bash node scripts/line-endings.mjs # both cells, all three densities node scripts/line-endings.mjs --cell phone --density spacious # reproduce one ``` `PROBES.endings` in the state catalog walks the journeys, rest states and overlays of the `prose` seed: the messy seed, plus a description whose paragraph gaps fall inside the fold and a long guide. A state with a grid, list or board runs at `compact`, `comfortable` and `spacious`; one without runs once. `test/line-endings-gate-browser.test.mjs` runs each of the six cells on its own and fails on any finding. ### Mandatory onboarding verification [The onboarding verification contract](docs/onboarding-verification.md) maps the required journeys, permutations and remaining coverage gaps to executable tests. `ONBOARDING` in `scripts/ci-test.mjs` runs on every ready PR, alongside `SMOKE`, regardless of diff selection. Missing mandatory suites fail planning. Keep the contract and regression assertions current in the same PR as a flow change.