bridge is a control plane for coding-agent fleets: a live board with confidence/risk/completion gauges, a gateway that claims tasks and dispatches them to agent CLIs on your machines, and a human inbox for sign-off. The public site covers what it does and why; this README gets the app running in your own Cloudflare account.
Just want to use it? The shared hub is live at https://hub.fleetbridge.run — sign in with GitHub at the same URL and bridge pair https://hub.fleetbridge.run; no deploy needed. The rest of this README is for self-hosting.
Everything runs on the Cloudflare free tier: one Worker, one SQLite-backed Durable Object, hibernating WebSockets, worker assets for the web UI.
- A Cloudflare account (free)
- Node.js 20+
- One machine to run a runner on (macOS or Linux) with opencode installed
git clone https://github.com/state-vector/bridge && cd bridge
npm install
cd packages/worker
npx wrangler login # or: export CLOUDFLARE_ACCOUNT_ID=<id>
npx wrangler deploy
# note the printed URL: https://bridge.<your-subdomain>.workers.devSecrets (stored server-side, never in the repo):
npx wrangler secret put BRIDGE_INGEST_TOKEN # generate: openssl rand -hex 24 (legacy FLOWS_INGEST_TOKEN still accepted)The web UI is gated by GitHub OAuth (30-day HMAC session cookie):
- github.com → Settings → Developer settings → OAuth Apps → New OAuth App
- Homepage:
https://bridge.<your-subdomain>.workers.dev - Callback:
https://bridge.<your-subdomain>.workers.dev/login/callback
- Homepage:
- Store the secrets:
cd packages/worker
npx wrangler secret put GITHUB_CLIENT_ID
npx wrangler secret put GITHUB_CLIENT_SECRET- Set the login allowlist (default deny — without this secret only invited GitHub logins can sign in):
npx wrangler secret put GITHUB_ALLOWED_USERS # comma-separated GitHub logins (e.g. "rayh"), or "*" for any accountInviting teammates: workspace members self-serve — the 👥 button next to the workspace picker invites/removes members by GitHub username (validated against the GitHub API at invite time; optional GITHUB_TOKEN secret raises the lookup rate limit). An invited login can sign in via the GitHub button even when not on GITHUB_ALLOWED_USERS. Everyone holds the same rights — any member can invite or remove — and the workspace creator cannot be removed.
The runner claims todo tasks from the board and dispatches each to an agent CLI (opencode) in an isolated git worktree — one PR per task, progress reported back to the board. Worktree teardown is eventual (BR#3633): a done task's tree goes immediately (uncommitted work preserved onto a wip/<task>-<date> branch first); any tree no running session holds is swept by the periodic hygiene pass — at once when the task reads done/superseded/todo, otherwise after 7 days regardless of status — so a hub outage, an unresolvable task id, or a parked card can never pin disk forever; trees drain oldest-first, capped per tick. Claim races are noise, not failures: before claiming, the runner re-reads the card and skips silently when another dispatcher won; a card still in_progress whose dispatched session died (no fresh session stamp or hub heartbeat for 10 minutes — the same bar the watchdog uses) has its stale claim released and is re-claimed fresh; and the dispatch circuit breaker counts only genuine agent-CLI failures (spawn, auth, guardrails, provisioning) — lifecycle rejections from races never count toward it.
curl -fsSL https://raw.githubusercontent.com/state-vector/bridge/main/install.sh | bash
bridge pair https://bridge.<your-subdomain>.workers.devPairing prints a device code — approve it in the web UI (/pair) and the runner starts. It stays running; bridge status shows what it's doing. Useful env: BRIDGE_MAX_SESSIONS (WIP limit), BRIDGE_WORKDIR, BRIDGE_AGENT (opencode agent name), OPENCODE_BIN. The device declares its class at pair/start (a runner pairs as a gateway — there is no checkbox) and its label is pre-filled with the bare hostname slug (e.g. macbookpro), editable and display-only — the label is never identity. The gateway class comes from the token's gateway tag, not the name, and Connect lists gateways under Gateways with the agents they dispatched, separate from your interactive agents.
Prefer building from source?
cd packages/cli && cargo build --release
./target/release/bridge pair https://bridge.<your-subdomain>.workers.devLiving on the edge? Every push to main rebuilds the binaries into a rolling dev pre-release (releases/latest and plain install.sh never see it):
curl -fsSL https://raw.githubusercontent.com/state-vector/bridge/main/install.sh | bash -s -- devThe runner is a long-lived process: keep it alive with systemd (linux) or a LaunchAgent / nohup (macOS).
bridge doctor # checks config, token, agent CLIs + agent profiles, git, and the merge-train executor prerequisites (gh authed + repo scope + worker-class identity + lane-repo push, git) — every failure names the fix
bridge pair https://bridge.<your-subdomain>.workers.dev
bridge service install # linux: writes systemd user units — supervisor (Restart=always,
# backoff + jitter) + death telemetry + give-up escalation
systemctl --user enable --now bridgeHeadless log view — everything the fleet did, same event feed the webapp's Log view renders:
bridge logs # last 30 events, oldest → newest (age · actor · op · task · detail)
bridge logs --limit 100 # deeper
bridge logs -f # follow — polls every 5s, prints new events live
bridge logs --task BR#123 # filter to one artefact
bridge logs --json # raw JSON for scriptsThe unit logs to ~/bridge.log (both streams, append:) instead of the journal — user journals are unreadable on many minimal cloud images (journald Storage=volatile, or the session predates linger), so journalctl --user dead-ends there while the service itself runs fine. It also bakes the install shell's PATH into the unit (with the configured agent CLIs' dirs first): systemd's minimal default PATH misses ~/.local/bin, which left agent CLIs unrunnable under the service. Re-run bridge service install after moving agent CLIs, or set PATH in ~/.bridge/env to override.
The daemon's death is loud (BR#4682): the unit uses Restart=always with exponential restart backoff (5s→60s) and a 0–7s start jitter, so the runner revives from a crash or a clean exit without human action. Every restart-causing death fires an ExecStopPost hook that posts one [daemon-death] note per distinct cause (result / signal / exit code + last log lines) to the alarm sink card — deduped to one note per 10 minutes per cause, so a crash-loop can't spam the board — and always appends a local receipt to ~/bridge-death-telemetry.log (the no-silent-deaths guarantee even when the hub itself is unreachable). Deliberate systemctl stop/restart log locally without alarming. If the supervisor ever truly gives up (start-limit-hit, effectively unreachable at 50 starts / 10 min against the 60s backoff cap), a separate give-up escalation unit posts one [daemon-death ESCALATION] note naming the manual intervention needed. The unit's WorkingDirectory is the user's home — never the directory the installer ran from.
Runner log format (BR#4400): every operator-visible line from the runner — dispatch loop, in-process merge-train leg, store/session/keychain, CLI, canary — flows through one mechanism with one shape:
[2026-10-28T22:32:25Z] [train] [state-vector/bridge@main] queue: BR#4363 → BR#4362 → BR#3822
[2026-10-28T22:33:10Z] [dispatch] [BR#4371] 21m in · heartbeat text…
[2026-10-28T22:33:12Z] [train] tick error: hub rpc error (train_lease): kb temporarily unavailable — retry
Fields: [<iso-utc>] [<subsystem>] (train | dispatch | pair | store | session | keychain | cli | canary) [<scope>] (the lane, card, or workspace the line is about — omitted when process-global), then free text where event-style prefixes (queue:, lease:, error:) are the convention. The desktop gateway and the bridge CLI compile the same module (packages/desktop/src-tauri/src/logging.rs), so both binaries emit identically.
Server prerequisites: the agent CLI itself (e.g. opencode) installed and authenticated — bridge doctor verifies. Per-agent models are required ("models" in ~/.bridge/config.json); the runner refuses to dispatch without them. Models are the agent CLI's business, not the hub's: the runner injects the pinned id into the agent CLI's own config, and the CLI resolves it against its own provider config — bridge doctor reports the pin (and warns when unset) but does not validate it against anything hub-side. ~/.bridge/config.json is the knob surface: max_sessions, backends, agent, poll_secs, timeout_mins, model overrides.
The desktop app (gateway with UI) is packages/desktop: npm run dev -w @bridge/desktop.
packages/mobile is the no-gateway surface: ui/ is the Fleet + For You +
Voice tab (browser-testable Vite/React over hub REST + WebSocket), shell/
is a Tauri 2 iOS target wrapping it (com.statevector.bridge.mobile).
The Voice tab is the narrow desktop chat, by ear:
- Tap to talk —
SFSpeechRecognizerwithrequiresOnDeviceRecognition: on-device speech-to-text, live partial transcript, audio never leaves the phone. Answers are spoken back withAVSpeechSynthesizer(offline voices); tap mid-answer to interrupt. - The brain is the same engine as the desktop console — Apple's
on-device model (Foundation Models, iOS 26+ with Apple Intelligence) —
but it drives bridge MCP tools over a prompt-protocol loop: it can
orient,search_knowledge,get_artefactand read the ask inbox before it answers, citing BR# ids. Read-only by construction: it never claims or dispatches work, and answering an ask is confirm-first (the app reads the proposed answer back; you confirm by voice or tap). - Connect: hub URL + a bridge token pasted from the hub's Connect tab (the machine plane — same token class an agent uses). The Fleet/For You tabs keep working with the OAuth web sign-in as before.
Build (simulator, no signing needed):
npm install
npm run build -w @bridge/mobile-ui
cd packages/mobile/shell
npx tauri ios build --target aarch64-sim
# product: gen/apple/build/arm64-sim/Bridge.app — boot it in any iOS 26+ simulatorOne rendering path. The web UI and the desktop shell render the same
view components (Top, Backlog, Completed, task detail with dep graph and
live work tree) from the shared kit @bridge/ui-kit, over the same data
layer — the desktop opens a live WebSocket to the hub with its paired
gateway-class token (bearer on /api/kb/* + /kb-ws; admin routes stay
cookie-only), so both surfaces always look and update identically.
The board is push-driven — no polling (BR#4362). The socket carries
state, not just a doorbell: every mutation streams a kb.event frame, and
the hub pushes a coalesced kb.board snapshot (at most one board build per
second, no matter the mutation rate or how many clients are connected) plus
a hydration snapshot on connect. Clients apply frames to their store and
fetch nothing on a timer; GET /board stays as a 2s-cached hydration path
for stragglers. Desktop shells whose webview can't hold a cross-origin
socket tail the events mirror adaptively (2s while busy, backing off to 30s
idle) and coalesce event bursts into one refresh — the 2026-10-29 overload
was every client refetching the full board per event. Pull surfaces are
rate-limited per identity as a backstop (/board 30/min, /events
120/min; 429 + Retry-After naming the socket) — pollers brake themselves.
Beyond dispatch it carries three human-facing surfaces:
- For you + Chat — the decision inbox (proposed decisions, blocked work, open questions, read-only) and a console chat: ask about the board in natural language. Retrieval runs over the hub (
/api/kb-machine/search, paired token); the answer is summarised by Apple's on-device model (macOS 26+ with Apple Intelligence) and falls back to the configured backend CLI one-shot otherwise. The console never claims or writes work. - Decision notifications — the app watches the inbox and raises an OS notification whenever a new proposed decision arrives (the click jumps to For you). Verify-verdict notifications state the ask ("Accept verification of BR#X? — verdict PASS"), not the raw receipts title. No runner required.
- Guided sign-back-in — if the run gate signs the desktop out (the identity law reserves gateway dispatch for gateway-class tokens: a pre-gateway token surviving an app upgrade), the sign-in screen says so in plain language — what happened, that nothing was lost (hub URL and settings are kept), and that signing back in is one click: the browser opens itself, and the vended gateway-class token re-associates with the same stable device id. Legacy board rows keep their labels; only this device's token rotates. (A token the hub rejects mid-run is not a sign-out anymore — see the self-heal law below: the gateway parks loudly and recovers on its own, BR#4113.)
All runner knobs live in ~/.bridge/config.json (written through a single
atomic save; safe to edit by hand — settings in the desktop app apply by
updating the same file). Credentials never live there: the gateway token
is stored in the OS keychain (data-protection keychain on signed macOS
builds; ~/.bridge/gateway-token, mode 0600, on unsigned dev builds and
Linux), and the device principal is write-once at ~/.bridge/principal
(0600) — bridge reset-principal is the only local clear. The principal is
the device credential and anchors identity: the hub vends it on first pairing
(dev_ + 24 random bytes hex, generated once, never regenerated), the device
presents it on every pairing and desktop login, and re-pairing or re-login
rotates the gateway token in place against it — the hostname is a display
label only:
Rotation is graceful and self-healing (BR#3787) — a gateway is never killed by a rotation and never needs a manual re-pair:
- Identity law: provisioning and pairing match tokens by the principal only. A label held by another device's credential is a collision — self-provision mints a suffixed row, human pairing refuses with a named error. Rotation never steals another device's token (the 2026-10-27 fleet 401 ping-pong was exactly this theft).
- 72h grace: when a token rotates, the previous hash keeps authenticating for 72 hours, flagged stale. Rotation is a re-vend signal, not an amputation — in-flight dispatches finish. Admin revocation still kills both hashes instantly.
- Negotiated self-heal: every authenticated response carries
x-bridge-token-epoch; the runner re-vends its own row proactively at the next tick boundary when the epoch moves (POST /api/token/repair, agent plane — the principal is the credential, so headless gateways self-heal without a human session). On a hard 401/403 the runner classifies the failure and runs the self-heal ladder — keychain reload (another process may have re-vended), principal repair, desktop self-provision — with exponential backoff (5s doubling, 300s cap), and every repair failure reason is logged, never swallowed. Auth failure is never terminal while desired state is "running" (BR#4113): there is no logout-and-halt path left in the loop. If the ladder stays blocked the gateway PARKS LOUDLY — aneeds attentionbanner in the app (andneeds_attentionin~/.bridge/dispatch/runner.json) — while it keeps polling and retrying on the capped cadence, resuming dispatch on its own within one poll interval the moment the hub accepts the device again (grace window, rollback, row restore) — zero human action. Only the identity-law gate (a saved token that is not gateway-class) signs the desktop out, and only a lost/revoked principal forces a human re-pair; an admin revoke parks the gateway loudly (it claims nothing) instead of stopping it silently. Repairs are rate-limited per token (6/hour) so a crash loop can't mint infinitely.
Owner columns render labels, never anchors (BR#4262) — the owner stored on
a card is the principal (the matching anchor); every display surface
(board, orient, get_artefact, my-tasks) resolves it through a render-time join
to the owning token's label, falling back to the stored value verbatim
when no token row matches (legacy system:* owners, hand-minted labels).
Matching never uses the rendered label. And the namespaces never impersonate
each other: new principals are vended dev_-shaped (generatePrincipalId —
a principal can never be mistaken for a bkw_ token; existing principals are
not migrated), and every label door (token.rename, pair approve,
agent.provision, human mint) rejects bkw_/dev_-prefixed labels — the
2026-10-28/29 incident was a token-shaped string pasted as a label that then
rendered into owner columns.
Machine/agent names are display, not identity — renames alias, never mint
(2026-10-29): a hostname change (macOS local-name rotation, the network's
-2 dedup suffix, a VPS's DHCP IP, a lost principal file) used to mint a
fresh gateway identity and — because agent labels are namespaced
<gateway>/<agent> — a fresh identity per provisioned agent, piling dead
labels on the board. Now every rename through the principal anchor records
the old name in identity_aliases (old → current), and:
agent.provisionunder a renamed gateway renames the agent token in place (same id, same hash — the runner's stored secret keeps working) instead of minting<newGW>/<agent>;- the identity list (
/agents) collapses each identity to its live label and carries the retired names asaliases(gateway→agent edges resolve across the alias set too), so history reads through one name; admin.alias {alias, canonical}merges the legacy dead labels that predate the principal anchor (human asserts the mapping once;admin.aliaseslists the table; a retired name can never be a canonical, so chains stay forward-only).
Dispatched sessions commit under a first-class identity profile — not
ad-hoc -c overrides. At worktree provisioning the runner writes the
profile into repo-local git config on the runner's own clone (the
config that clone's worktrees share — never --global: user.name,
user.email, user.signingkey, gpg.format=ssh, gpg.ssh.program,
commit.gpgsign=true); your global git config is never touched, so the
shared checkout keeps its own identity.
- Built-in class
worker: every dispatched session defaults todetached-cody/297316395+detached-cody@users.noreply.github.com, signing with~/.ssh/sv-agents-signing.pubvia plain/usr/bin/ssh-keygen(the BR#3181 ruling — no 1Password in the agent path). The values are the recorded contractpackages/contracts/fixtures/identity-profiles.json; the runner fails its own test suite when the built-in drifts from it. - Precedence: an explicit
identityblock in the config beats the built-in class; the built-in class beats whatever is in the host's global git config for sessions dispatched into worktrees. An agent overridinguser.*ad-hoc (-c) drifts the committer identity away from the class — the verify gate bounces that. - Validation leg: provisioning ends with a scratch sign check through
the profile's own program and key (
ssh-keygen -Y sign -n git). A missing key, wrong program, or hung signing program refuses the dispatch loudly (like the smoke gate) — the task is noted on the board and the runner retries next cycle; unsigned commits are never shipped. - Board surfacing: Connect nests each gateway's provisioned worker agents under it with their identity class, and the pairing screen states the class the device declared plus what its runner's dispatches commit as (BR#2455 grouping, reused).
If the hub rejects the token (401), the runner attempts one automatic repair (re-vend via the live login session) and, failing that, logs out loudly with a human-readable reason — the token never silently vanishes.
If no agent CLI is dispatchable, the runner says so loudly every tick and exits non-zero once in-flight sessions drain — systemctl status bridge shows the failure and Restart=always (with backoff + jitter, BR#4682) revives it; a non-dispatchable agent CLI is a fix-it-then-restart situation, bridge doctor diagnoses. Under systemd, use absolute bin paths or a PATH override for the unit: the service does not inherit your shell's PATH.
Notes:
- Per-provider config: models live in the
modelsmap keyed by provider — opencode needs an explicit model (the runner refuses to dispatch without one; the CLI's own default is how a stale pin silently downgrades a fleet). Kiro is profile-driven, so it needs no model entry. - Identity: gateways and agents share one label namespace with a class
boundary — gateway tokens are infrastructure labelled with the hostname slug
(e.g.
macbookpro) and tagged with the gateway class; they are never task owners, and agent labels (opencode-ray) never take the gateway tag. The token (its principal) anchors identity: the class is the gateway tag on the token, never a name convention — labels are free-form display and nothing gates on a label shape (a label held by another device's credential is a collision, not a naming violation). Connect shows gateways with the agents they dispatched, attributed agents nested under their gateway. Dispatched work is attributed to the agent the gateway dispatched as, named<hostname>-<backend>(e.g.macbookpro-opencode) or overridden with"agent_name"in the config. Legacy tokens keep their labels — the boundary is enforced by the gateway tag on the token, never by the name. - Model cost: whatever you set here is what every dispatched session uses.
Check the opencode CLI's own config (
~/.config/opencode/opencode.json,"model"key) if spend looks high despite a pin here. - Reporting: the runner relays each agent's real stdout lines to the board
as it works, so narration = liveness. Set
"report_minutes": 5only if your agent produces no narration at all.
Agents join the fleet by pointing their MCP config at the hub. The config format is per-client — the Connect tab's setup modal emits the right snippet with your token slotted in.
opencode — add an mcp entry to ~/.config/opencode/opencode.json
(note: the key is mcp, not mcpServers — opencode silently ignores the
latter, and an agent with no bridge tools will waste its run hunting for a
CLI):
{ "$schema": "https://opencode.ai/config.json",
"mcp": { "bridge": {
"type": "remote",
"url": "https://bridge.<your-subdomain>.workers.dev/mcp",
"enabled": true,
"oauth": false,
"headers": { "Authorization": "Bearer <token from the Connect tab>" }
} } }oauth: false matters for headless use: without it opencode tries a browser
OAuth flow when the hub returns a 401, which hangs a dispatched session.
Generic mcpServers clients (claude code and similar):
{ "mcpServers": { "bridge": {
"type": "http",
"url": "https://bridge.<your-subdomain>.workers.dev/mcp",
"headers": { "Authorization": "Bearer <token from the Connect tab>" }
} } }Verify with opencode mcp list — bridge should list the board tools.
Config is merged per-server-key, so the bridge entry coexists with any other
MCP servers the host already uses.
Dispatch hosts: the runner spawns opencode run as a child process, so the
agent CLI resolves its own ambient config — a dispatch host needs this
entry for whichever CLI the runner drives, or dispatched sessions start
toolless.
Mint agent tokens in the web UI under Connect. Agents call orient first — it returns the protocol card, open work, decisions awaiting consensus, and the agent's own behavioural score. Every artefact row an MCP tool returns carries url — the card's canonical deep link (https://<hub>/card/BR-3990) — so an agent citing a card can hand a human the link that opens it (BR#3990). Emitted links always use the canonical origin — the CANONICAL_ORIGIN var (default https://hub.fleetbridge.run; set it to your own domain when self-hosting on a custom domain), never the ingress host a request happened to arrive on, so a hub reached via its fallback hostname still hands out canonical links (BR#4695).
Receipts match the deliverable type: dispatched verify/analysis/unblock sessions deliver findings and receipts on the board (progress notes and knowledge artefacts, citing GitHub PR/issue/run URLs) — they don't open PRs. Only code work opens a PR. A URL that isn't a GitHub PR/issue/run URL is not a receipt, and a dispatch run that was provider-blocked (no model, no endpoints) can never hand off as complete.
Tokens are lane-tagged: role:analysis, role:impl, role:review. The hub composes the
matching playbook into every orient for that agent (under YOUR ROLE) — analysis
decomposes and qualifies (never builds), impl builds and hands off at in_review with a
PR link, review verifies with receipts in a fresh session. Separation of duties, pickup
gates, and verify auto-spawn are enforced server-side in packages/worker/src/kb/do.ts,
not by client convention.
Verify is the merge authority; the merge train is the executor (standing order BR#3033,
re-divisioned by BR#3516). The verify round reviews substance and records an accepted or
rejected verdict — its accepted verdict is what licenses the merge; the mechanical merge
itself is executed by the merge train (the next section): a credential-holding edge
executor that runs the 5c gate and merges when the verdict covers the current head and
checks are green. There is no separate human merge gate for a verified PR. The cycle is:
impl works → rebases → pushes a mergeable PR → in_review; verify reviews substance
(mergeability confirmed first — rule 5c); findings → bounce back to impl
(in_review → in_progress, one round, N bounces — the same verify round re-enters, it
never stacks); loop until the train merges and closes. Impl owns mergeability
preparation (rebase before every in_review — a conflicting PR is the impl's defect, not
the reviewer's); the train owns mergeability enforcement (a conflicting, red or unsigned
PR is bounced with the exact defect — never merged silently).
Bounces name their kind, and the queue is durable (BR#4066/4067/4068): a train bounce is
one of three kinds with different next owners — conflict (trial-merge files listed; impl
repair leg), coverage (the live head isn't covered by the accepted verdict — no conflict
exists; the card immediately re-enters review and a fresh verify duty re-checks the head, so
the stamp-race class self-heals), or gate (the named mechanical defect). The hub stamps
the kind, defect and files on the card and boosts it to priority 0 — a bounced
merge-ready card is the board's highest-value work and must not re-enter at default priority
and starve. A fresh verify accept on a non-conflict bounce re-queues the train automatically
(no agent hands the card back). The coverage self-heal is gate-proof (BR#4494): its
in_review re-entry note carries the PR link the review gate requires, so the demanded
re-verify duty always spawns instead of the re-entry silently stranding the card
in_progress. And machine silence-releases (watchdog sweeps) can no longer
destroy queue state: a card the train owns (queued / awaiting-ci / bounced on a live PR
leg), or one parked on a typed await_event or a bound ask, is never released to todo — the
sweeps park it blocked with the typed proxy intact (the proxy owns its exit), and the
machine-actor backstop refuses any release path that slips past them (BR#4067). Humans and
owners still release freely. The hold-park itself runs only AFTER an atom check (BR#4282): The one exception is the bounce re-entry (BR#4494): a bounce re-opens its card in_progress with the prior — usually dead — executor session, and in_progress is not claimable; for a bounced card the train has already handed the leg back to the board, so when the re-entry sits silent past the stale window the sweep demotes it to a claimable todo (bounce brief and boost intact; result class bounce_reentry_demote, exempt from abandonment scoring) instead of re-parking it — queued/awaiting-ci keep the full hold.
the hub holds no VCS credentials, so a pr_merged proxy is evaluated against BOARD receipts
only — the train's own awaiting-ci stamp (with merge_sha) or the provenance row. A
proxy that is already satisfied is never re-armed: instead of re-parking the card blocked,
the sweep spawns the owned done-close duty — which, when the parent sits in_progress or
in_review, closes ITSELF done first and then the parent (the exit flipped while the card
worked; it owes a close, not a resume). The silence-re-park can also no longer loop: a
flood-guard skip (skip_unblock_spawn with flood: true) is the dedup working and is
audited without folding a breach card, and a resume consumes the unblock round so post-resume
silence issues one fresh round instead of flooding for an hour. The spawner cap re-suppresses
the same way (BR#4702): once the BR#2893 escalation stands, further capped attempts audit
spawn_cap_skipped without folding a breach card — the escalation, not a per-tick breach
card, carries the recurring block to the human ledger.
The trains panel is honest about capability and activity (BR#4981): a held lock names
its machine; an idle lane ticked by a merge-capable gateway inside the 15-minute window
shows the pill train ready (the tooltip names the machine and its last tick age);
only a lane with no capable tick inside the window says the muted pill no gateway;
a gate in progress on the queue head reads attempting (with the head card and the
last ledger action in the tooltip); a protection-blocked head reads ⏸ bypass wait;
a retired lane reads retired.
The lane row is three labeled time-frames (BR#5114, Ray 2026-10-30: "I want to know
WHAT is being done right now and progress, what is up next, what is in the queue
remaining"): NOW — the status pill + in-flight PR + phase age PLUS the holder's
live last action verbatim from its tick log (the BR#4104 proof-of-progress seam,
hub-consumed: meta.lane.now_action, rendered only while fresh against the 5-minute
lease TTL — a held head SAYS why it is held, e.g. "PR #2588: head check RED:
Meta-review nightly", instead of a bare gate pill for hours); NEXT — the first
queued card + its PR; QUEUE — the remaining cards with their PRs and depth. The
executor stamps the gate's Skip/Note verdicts into that seam (ctx.note on the gate
outcome), the hub persists the latest renewal note, and the throughput rate + last
completed event (with age) close the NOW column so "last" and "now" are never
conflated again.
One card may carry several PRs (BR#3659): a verify close that accepts multiple PRs
cites every PR URL with one head <sha> after each — PR <url> head <sha>; PR <url> head <sha> — and the hub queues one leg per PR (meta.prs; pairing is
line-adjacency based: a head after ; belongs to the next clause's PR, never the
previous one, and a cited PR without its head fails the queue closed with the card
stamped unbound). A single-PR close keeps the pre-3659 shape (the note's one URL
binds its first head <sha> wherever it sits). The citation itself is
convention-tolerant (BR#4648): a PR cited as the board's own owner/name#N
shorthand — the way every bounce note and brief writes it — parses like the URL
(the URL form wins when both cite the same PR), and a head-bearing close with no
PR citation at all refreshes a card that has exactly one open leg (multi-leg
stamps stay fail-closed — a lone head is never guessed onto one of several
legs). The re-queue note is honest about it: it says stamp refreshed only when
a stamp actually moved, and an accept whose head <sha> cannot bind any leg is
audited loud (queue_stamp_not_refreshed) instead of silently re-queuing a
stale stamp — the silent-stale class that once spun coverage bounces on an
unchanged head (five accepts on BR#3764 while the stamp never left the old
head). The card closes only when its last
leg lands — after a partial landing it stays queued with an honest reason naming the
legs still out, and the train drives the next leg on a later tick. A bounced card is
NOT re-queued by a stale verify close (the bounce stands until the card re-enters
review and a fresh verify duty accepts the new head) — the close-note scrubbing
workaround from BR#3651 is obsolete.
- Hub defaults:
DEFAULT_ROLE_PLAYBOOKSinpackages/worker/src/kb/core.ts. - Per-hub overrides: admin settings → Roles & playbooks (
settings.roles.*); clearing an override resets to the hub default. - Subagent prompt seed: the pinned card at
packages/adapters/bridge-protocol-card.md. - Full contract:
docs/roles-and-playbooks.md.
On tasks, roles live in a dedicated roles column — create_artefact { roles: ["review"] } —
never in tags. tags are 100% free-form user/agent labels (themes like gtm, deploy-train,
pricing): they render as chips and drive the filterable theme bar in Top and Backlog, but they
never change system behaviour — tagging a task role:review does nothing. Questions are their
own artefact type (type: "question"; asks create them automatically): open participation,
answered via the ask band or progress entries, closed to done/superseded — never claimed.
Humans can hold a question (BR#3911): on any open question card, Claim — I'll handle it
(web + desktop, human session only) stamps a 10-minute hold (human_claimed {by, ts, expires_ts})
that dismisses the question from the agent pool — agent inboxes drop it, orient says
in hand by <you>, and agent contributions get a stand-down postscript. Claim ≠ answer: protocol
6b stands, the question still closes via your ADR. Answer or close before the window and the
timer cancels; no-show and the watchdog sweep auto-reverts the hold, leaving an expiry note on
the card (honest churn, never silent). Release puts it back early; agent claims on questions
stay forbidden, and the runner claim path is untouched (the hold is a meta stamp, not a
transition). Window: question_claim_minutes setting (default 10).
ask_agent writes no side table — the ask is a question task: a BR#-numbered
type: "question" node, child_of the subject task when you pass artefact_id. The
subject goes blocked, the watchdog pauses for it, and an answer auto-resumes it back to
in_progress — exactly once: the ask is the unblock path, so an ask-bound block spawns no separate unblock/sweep duty that could race the answer (the card carries an awaiting_ask marker the machine's spawn gate reads; answering clears it). But only onto a live holder: the resume checks the holder's real liveness signals (session-alive vouch, last heartbeat, session/owner ping) against the same stale window the watchdog uses, and a dead holder (no live signal, ask older than the window) is released to the pool for fresh dispatch instead (BR#3819) — an ask can never resume work onto a session that already finished. Three fields are the whole contract, enforced server-side (askCreate / The fields are the whole contract, enforced server-side (askCreate /
askAnswer in packages/worker/src/kb/do.ts):
question— the summary. One answerable sentence ending in?, ≤240 chars; it becomes the node title. Status reports are rejected at the door — receipts and narrative go indetails.details— the plain-reading brief. Mandatory on human-directed asks (rejected without it): what the subject is, the current state, why it needs a human — every BR#/PR# reference glossed in one clause. An ask must be answerable without opening another card.reason— the why_escalated. Mandatory on human-directed asks (rejected without it): one sentence on why this is escalated instead of automatic — the gate, credential or authority it waits on. ≤1200 chars; renders as the ask's one-line why (never chopped mid-clause — the full story stays indetails).options— the decision. 2–8 one-click answers (rejected with fewer): the recorded click is the answer, and it closes the questiondone. When the asker recommends an option it is first and prefixed(Recommended)— the first button is the recommendation.
Asks carry ordinary BR# ids, so refs linkify wherever BR# refs render (cards, task detail —
the ref scanner in packages/ui-kit/src/views/refs.ts) and resolve via get_artefact like
any artefact. answer_ask takes the question node's BR# id — there are no separate ASK-*
artefact ids. Humans answer in the app: the ask band in Top (web + desktop) renders every
open ask with its options as one-click buttons, every ask-band row (verdict, decision,
node ask) shows its own BR# id as a mono chip next to the rel time — you always know which
ask you are acting on — and each node-ask row ends with a type your own answer… row
that opens a text input: the typed text is recorded with the ask exactly like a click
(BR#3912 — an option that promises a rewrite, like "RE-SCOPE — I'll rewrite the card
myself", needs somewhere to write it). Typed answers follow the same dispatch law as
clicks: an answer starting with a RE-SCOPE/KEEP option verb keeps the subject parked,
RELEASE/CANCEL releases it to the pool, SUPERSEDE drops it, anything else resumes it.
urgency is an ask vocabulary, not a
sentiment: "time-sensitive" renders the urgent NOW chip in the ask band; anything else is
normal — agents can no longer file an "urgent" ask that renders as a quiet one.
Asks to offline agents escalate to you (BR#3942). An ask addressed to an agent identity
that sits unanswered past ask_escalation_minutes (a setting, default 30, clamped 5–240)
while that identity is offline — no direct token or gateway-dispatch edge seen in the last
10 minutes, or never seen at all — is escalated by the watchdog to the human planes: the
same ask node is stamped escalated (one re-route, never a duplicate ask), the ask band
and For Me render an ESCALATED — addressee offline chip with the proof attached, and the
row shows the addressee's presence (offline · last seen Nm ago / never seen) so a stalled
card tells you who it is waiting on and whether that identity is even alive. The original
options stay on the ask and answering it resumes the blocked subject as usual.
Human-directed, open-floor (any), capability-tag and session-scoped (agent@session) asks
never re-route.
Human comments post from every web surface. The comment boxes (task detail's
"Comment / steer", the decision popover, discussion replies) all write to the card's
history through the same door agents use — the web plane maps POST /api/kb/progress to
the knowledge object's progress.log op, so a comment from any surface lands on the card
instead of 404ing — and a failed post shows the server's message, not a bare "comment
failed". The task detail panel loads the card, its history and its related tasks in
parallel: each section renders as its own data arrives, carries its own error + retry, and
the non-essential related list times out to nothing instead of holding the panel hostage. Every section races its own timeout — the card and history get 15s, related enrichment 5s — so a stalled fetch answers with a named error + retry (or an explicit loading spinner) and never leaves the panel a bare shell.
Claim locally — next to Reframe on every open ask (both surfaces: the ask band
rows in Top and TaskDetail's AskSelfCard), a Claim locally button copies a
ready-to-paste takeover prompt for a local interactive agent (e.g. an opencode/claude
session on your machine): the target card (id, title, status, owner, part_of, github ref),
the card body (acceptance/spec) plus the latest progress note, ask context when present
(reason, options), and the full takeover protocol — orient with a unique session, verify
state first-hand (work may be partially done), claim via transition, heartbeat ≤10 min,
shared-checkout discipline (worktree off fresh origin/main, explicit-path staging, never
git add -A), commit+sign as detached-cody (canonical invocation inline), finish = PR +
in_review, verify separation, human legs escalate to you. The button only copies text —
it mutates no card state; if the clipboard API is unavailable the prompt opens in a
fallback window instead, so it is never lost.
A blocked card without something it is awaiting is structurally impossible. transition {to: "blocked"} requires a reason AND a typed await_event from a closed grammar — a machine event (pr_merged, br_pr_merged, issue_closed, br_done, run_success, pr_checks_green), an open ask (ask — the active human leg, in For Me), or a timer (time). Verdict-duty tasks (role:review / role:unblock) are refused at this door entirely — they can never park: their silence is declared with log_progress grace, and "cannot proceed" is itself the verdict (BR#4392). Free text (unblock_condition) is optional human-friendly annotation, derived from the structure when absent; text that parses cleanly to one atom is auto-typed at write time — and that includes the ask/time legs (BR#4062): the machine-proxy prose convention (Machine proxy (typed await_event ask ask_id=BR#N): …, (typed await_event time until_ts=<epoch ms>) — ISO timestamps normalize to epoch ms) parses to the same typed event the await_event param carries, and the gate's rejection names the param when nothing parses. Conjunction conditions parse whole (BR#3986): every BR#N done / PR #N merged / GH#N closed on a line yields its atom, and the proxy watch flips only when every atom evaluates TRUE — never on the first alone. Two park shapes are refused loud at the door: text that parses to multiple atoms (a park awaits one typed leg — name it in await_event and carry the rest as prose; multi-atom text never self-migrates, breach class gate:multi-atom-condition), and a self-referential await — a card naming its own done (br_done/br_pr_merged on itself) can never fire while blocked, the BR#136 deadlock (breach class gate:self-referential-await). The hub's proxy watch re-checks machine events every 15 min and spawns the unblock duty on the flip; an answered ask auto-resumes its subject (live holders only — a dead holder is released to the pool for fresh dispatch, BR#3819); an elapsed timer fires the duty — prose-derived ask/time parks self-migrate to the typed event and evaluate first-class like any typed park. Legacy prose-only cards run the disposition ladder (decideProseDisposition, BR#4416 — the machine resolves what it can prove from the card's own state, and a human is asked only for genuine ambiguity, never one ask per card): a park whose machine state already holds the exit proof (a provenance/train-ledger merge row, an answered bound ask, a terminal root blocker) auto-flips per its own promise — no ask exists (the BR#4170 class: a PR sat MERGED while a human was asked to re-scope); a park with a recoverable typed leg (meta PRs, train stamps, an open bound ask, an open root blocker) is re-typed in place and re-enters the normal typed path; a park with no machine leg at all is released to todo for re-scope (the BR#3578 no-leg law — the churn guard's park is not a human decision), except runner/human-owned or human-qualified cards, where supersede-vs-continue is genuinely yours: those collect into ONE class ask per day with each card's facts and consequence-naming options. Every sweep pass emits prose_blocked_count (visible in /stats and the prose_sweep audit line); after the BR#4248 generator-kill has merged and deployed, a nonzero count is an incident — ONE deduped alarm at the sink naming the cards and the writer that minted them, never per-card asks. Answering the class ask actions every card it names: RELEASE/CANCEL → released to the pool, SUPERSEDE → dropped, RE-SCOPE → each card re-blocks with a typed await (the answered ask is the record). The SUPERSEDE drop is the one blocked → superseded edge (BR#3913): it exists exactly for the answered ask — the answer is the authorisation — and for the web; a bare agent session cannot take it (transition {to: "superseded"} from a session is refused by the supersede-authority law row), and a drop closes the card's open unblock/verify rounds. MCP transition takes await_event (validated fail-closed; the schema lives in packages/contracts/src/mcp.ts, the grammar in packages/worker/src/kb/closure.ts). Two park shapes are refused loud at the door: text that parses to multiple atoms (a park awaits one typed leg — name it in await_event and carry the rest as prose; multi-atom text never self-migrates, breach class gate:multi-atom-condition; the refusal names every atom in short form so the full list survives the audit trail's 300-char reason slice, BR#4303), and a self-referential await — a card naming its own done (br_done/br_pr_merged on itself) can never fire while blocked, the BR#136 deadlock (breach class gate:self-referential-await). Legacy prose-only cards are migrated: parseable → typed, awaiting-nothing → one active ask per card (never re-asked). Answering a migration ask actions the card: RELEASE/CANCEL → released to the pool, SUPERSEDE → dropped, RE-SCOPE/KEEP → a deliberate human-owned park (the answered ask is the record). Two park shapes are refused loud at the door: text that parses to multiple atoms (a park awaits one typed leg — name it in await_event and carry the rest as prose; multi-atom text never self-migrates, breach class gate:multi-atom-condition; the verify duty's ACCEPT step names the train park's typed leg explicitly (await_event {event:"pr_merged", repo, n} rides the merge-train: queued transition, BR#4425), so the standard queue path never trips the door), and a self-referential await — a card naming its own done (br_done/br_pr_merged on itself) can never fire while blocked, the BR#136 deadlock (breach class gate:self-referential-await). Free text (unblock_condition) is optional human-friendly annotation, derived from the structure when absent; text that parses cleanly to one atom is auto-typed at write time. The hub's proxy watch re-checks machine events every 15 min and spawns the unblock duty on the flip; an answered ask auto-resumes its subject; an elapsed timer fires the duty. The server holds itself to the same law: the churn-guard redirect (a card released to the pool 5 times, or 5 silent watchdog cycles) parks blocked stamped with a typed 24h time cooldown — the timer is the machine exit (on fire the unblock duty re-checks first-hand; a proven release resets the attempt counter), and the read-the-history text stays as annotation only. A post-condition invariant (blocked-await-invariant) refuses ANY internal blocked landing without a grammar-valid await, so the server can never mint the prose-only "awaits nothing" parks that force per-card human escalation asks (BR#4248). Supersede — next to Claim locally in the ask action row, and beside Unblock now on every blocked card (and on unclaimed todo cards): a Supersede… button retires a dead card in one click (BR#3910). The dialog shows the card's current status and takes an optional replacement task id (BR#<n>, #<n> or a bare number, validated before submit). The server composes the documented two-step itself — blocked → release → todo → superseded (the BR#1852/BR#1935 precedent) — so the raw intermediate states never surface; the card lands superseded with superseded_by plus a clickable replacement link, and the supersedes relation records the lineage machine-readably. Supersede is web/human-only (BR#2480): the route refuses gateway bearers with 403 and the agent-facing transition gate still has no →superseded edge, so no session can execute it. Every refusal surfaces the server's message inline and in the banner with the card left unchanged — a silent no-op is the defect class this button exists to kill (BR#3649's answer-before-click misfire, BR#3905's timer-spin churn on dead-premise blocked cards). Dead-premise cards whose premise can no longer hold are closed for good by the human-only Supersede button instead of looping through re-check duties (BR#3910).
Merges must not land while the destination branch is red — accepted decision BR#3009 makes that law, and BR#3008 makes it mechanical instead of discipline. Three halves, same rule:
- The block: every PR runs the required
main-green gatecheck (.github/workflows/main-green-gate.yml+scripts/main-green-gate.sh). It reads the check runs on main's HEAD (gh api repos/<repo>/commits/<sha>/check-runs): any failure (or a check still running, or no check runs at all) fails the gate and GitHub refuses the merge via themain: main-green gate + substance checks requiredruleset. The newest run per check judges; a cancelled run says nothing. The one exception is written into the gate: a PR green on the exact checks main fails passes with a printed REMEDIATION CARVE-OUT receipt — BR#3009's remediation-merge path, expressible and receipted, not a loophole. - The substance: the block judges main's HEAD; it never judged the PR's own head — the
hole #174 sailed through (merged with
Test+CLI build (packages/cli)FAILURE on its head while main was green; main went red and the #211 hotfix followed). The same ruleset now also requiresTestandCLI build (packages/cli)green on the PR's own head, withstrict_required_status_checks_policyon: the checks must come from the latest head, so a stale green never counts. A red-head PR cannot merge even while main is green — the train's gate (decideTrainGate, composing the standingdecideMergeActioninpackages/worker/src/kb/core.ts) enforces the same law: two reds never merge. Deliberately NOT required: theSuperpositionalreview (a failing review is a quality issue and merge-allowed — a required check would deadlock against it) and the BR#2267 externals (Workers builds / deploy previews never gate). The bypass actor is unchanged — rayh alone keepsbypass_mode=always: the human retains the loud escape hatch; machines do not. Proven end-to-end by probe PRs #216 and #217. - The stop (executor-fed, BR#3516): the hub holds no VCS credentials and no longer
reads GitHub — red detection is fed in by the merge-train executor: its awaiting-CI
poll posts the merge commit's check runs (
train.ci), the hub classifies them (classifyRequiredRuns— red, or unknown past 3× the lane's typical CI time, fail-closed), and any non-green verdict stops the line: the lane card readsmerge-train: STOPPED — … (resume when the line is green), the just-merged culprit card is bounced toin_progresswith the failing runs appended (red after merge), and ONE time-sensitive ask is filed per red episode to theALARM SINKcard (train_alarm_sink_idsetting, default BR#3124) — deduped per sha by the identical-open-ask guard, so ten red runs are one ask, not ten. Options ride the ask:Ack — on it/Revert the culprit/Waive — resume the lane. Green again → the executor's next CI poll feeds a green classification (train:landed) and the hub releases the stop automatically. v1 stops the line; there is no auto-revert — fix forward or revert is the culprit card's job. Work on branches (worktrees) continues — only the line stops. Two BR#5061 refinements keep the stop honest instead of stormy: one ledger episode per stop (the same sha + reason re-reported by a tick loop — the stop arm clearing on green main while the awaiting-CI arm re-fires it — is atrain:stopped_dedupedno-op, never a new ledger row every 20 seconds), and the phantom-required carve-out (Ray ruling 2026-10-30: "if it can't merge, bounce it and move onto the next one — the train goes regardless"): past the fail-closed bound, if every unsettled required workflow is MISSING (no check run ever existed for it on the merge commit — e.g. a PR-context-only gate that only runs onpull_request) while at least one required workflow proved green on that very commit, waiting cannot change the verdict — the merge LANDS judged on the workflows that exist, the never-run names are dropped with a loud audit, and ONE config-defect ask per episode goes to the alarm sink (fixrequired_workflows); a genuinely stuck OBSERVED run (or an empty, zero-evidence poll) keeps the fail-closed stop — the carve-out never fires on no evidence.
A stop-the-line episode now spawns its own cure, and the cure jumps every queue.
When the stop fires, the hub ensures ONE root-fix card for the red — tagged
root_blocker + main-red + p0, role impl, a child of the lane relation —
binding the culprit facts (repo, culprit head_sha, base, why, the
failing checks, bound_at), with repair legs riding it as they open (pr
stamped on the binding). Three paths, never a duplicate: the culprit card is
still open and qualified → it is re-bound in place; another open card in the
same repo qualifies → the binding moves to it; nothing qualifies → a fresh card
spawns. The red and the repair share one identity: the card the train was
stopped for IS the ticket that fixes it.
The queue sorts in four absolute tiers — root_fix > boosted > critical >
normal — root-fix beats even a human ▲ boost (the old boosted meta label
critical is renamed accordingly), and a red base halves the walk further:
when the lane's base is red the queue offers ONLY tier 0 — every other head
parks until main greens again (two reds never merge, and queuing green heads
behind a red base just rehearses the block). While the line is stopped, the
executor's stop arm falls through to the drain walk ONLY when the queue head IS
the cure — the bisect stays parked while the fix is in flight — and the
post-stop residue arm keeps its stop-null guard, so a cleared stop can never
re-arm from residue.
At the gate the cure fast-tracks: a queue head that is a root-fix card, with the
base red and its own head green, is not blocked by the base — the gate merges
it and stamps the receipt ROOT-FIX FAST-TRACK (BR#5189) (the merged train
event carries root_fix: true and a note suffix, so provenance shows the lane
was cured, not blocked). One circular case cannot self-serve: when the base's
red is the deploy-health alarm itself — the gate is judging the check that
reports drift while the drift is the breakage — the gate files ONE human ask,
"Merge the cure now" / "Not yet — hold the line", with a title byte-identical in
the Rust executor and the TypeScript hub (one implementation shared as fixture
tables, decideTrainGate) so the identical-open-ask dedupe matches whichever
side fires first; every other red-head fall-through stays under standing law.
Verify duty on a qualified root-fix card (tag + a stamped PR) names the fast
lane in its body, so the reviewer knows the merge path is pre-cleared before
they accept.
The Deploy drift alarm check (deploy-check job in .github/workflows/ci.yml, probe logic in
scripts/deploy-drift-alarm.sh) compares the live hub's __version commit against every main
push. The hub deploys asynchronously (git push → CF Workers Builds), so a first-probe failure read
"main is broken" on every push — 6+ false reds in 24h, each also tripping the main-green gate
on the next PR. The alarm now settles first: while the hub still serves an older build it emits
non-failing ::warning:: annotations for up to 10 minutes (SETTLE_WINDOW_SECONDS, default 600)
and goes green the moment the hub serves the pushed SHA — or a newer main tip containing it (a
faster merge superseding this push's deploy is not drift; drift is "deployed hub ≠ main tip").
Only a window that elapses stale fails loudly — real drift (CF Builds stalled or bypassed),
feeding the main-green gate and the merge train's base-green leg exactly as before — and so
does a hub that never answers. A failing main-tip lookup never turns a mismatch green
(fail-closed). Guarded by npm run test:deploy-drift-alarm.
Accepted decision BR#3516 re-divisioned the merge machinery: the hub holds no VCS
credentials, ever. Every GitHub fact is fed to it by an executor running on your own
machine; the hub decides (lease CAS, classification, events, queue) and only the
executor acts (gh). The hub-side sweeps that read GitHub — the standing merge-queue
sweep (BR#3198), the PR-disposition executor (BR#3312), the provenance sweep, the
GitHub-reading closure audit — are deleted, not deprecated: a hub that cannot reach
GitHub cannot lie about GitHub. The pure decision tables survive, pinned
(packages/worker/test/merge-sweep.test.ts); the executor's gate
(decideTrainGate → decideMergeAction) is one implementation shared with the hub,
unit-pinned in packages/worker/test/merge-train.test.ts.
Since BR#4104 the executor ships inside the gateway (Ray-directed completion of the
original BR#3516 spec): the daemon — the desktop app and the headless bridge runner
share one dispatch core — spawns the train tick beside its dispatch loop and contends
the lane lease exactly like the standalone command below. First-to-lease wins per lane;
a holder that dies lapses its 5-minute lease and the next gateway reconciles from live
GitHub state. No hand-installed systemd unit, no separate binary to update: the
train updates ride the normal gateway deploy path, and every gateway on a stock build
is an opportunistic executor. The identity law is enforced at the write boundary
(BR#3181): the tick probes its gh identity class — merge / update-branch / close legs
execute only under the worker-class credential (detached-cody); a personally-authed
gateway reconciles and reports read-only, then stands down LOUDLY from write legs and
releases the lease. Lease renewals carry proof-of-progress (last ledger action + age),
and the hub consumes it (BR#4129): a holder whose action note freezes while cards queue
is evicted — see the lease law below.
Capability honesty (BR#4489, ADR BR#4487): a gateway that cannot merge does not
pretend to serve the lane. bridge doctor classifies the host (gh api user against
MERGE_TRAIN_WORKER_IDENTITIES) and checks push permission on the lane repos; an
incapable host prints will not claim train lock as I cannot merge PRs and doctor exits
with the merge-train leg flagged (non-fatal for non-train functions). At claim time the
executor re-checks the classification on a ≤5-minute cache — startup truth is not
runtime truth — and a non-capable host skips lane contention entirely (the skip logs
once per 10 minutes, not every tick). If a merge write is refused mid-flight (403 /
permission), the executor releases the lease immediately and self-demotes for the
process lifetime — it will not claim the train lock again until the gateway restarts
with a fixed credential. Leases carry merge_capable, so the hub can tell a served
lane from a pretended one.
bridge train remains the standalone form of the same module (decision BR#3880 —
install is the unit: no Node, no script file). It runs on any machine that has gh
(authed, with the repo scope), git and a clone of the target repo — or, with no
MERGE_TRAIN_WORKDIR, it manages its own clone cache (~/.bridge/train/<owner>/<repo>)
like the daemon leg does. The gate is one LAW with two fixture-locked implementations
(decideTrainGate: TypeScript hub-side, Rust executor-side — mirrored test fixtures
pin them in lockstep):
cd /path/to/repo-clone # the repo the train drives (MERGE_TRAIN_WORKDIR)
export BRIDGE_URL=https://bridge.<your-subdomain>.workers.dev
export BRIDGE_TOKEN=<token from the Connect tab>
~/.local/bin/bridge train # long-running loop (45s ticks)
~/.local/bin/bridge train --once # single tick (cron-friendly)Env (standalone and daemon leg alike): MERGE_TRAIN_HOLDER (lease label, default
merge-train@<host>), MERGE_TRAIN_INTERVAL_SECS (default 45), MERGE_TRAIN_METHOD
(merge | squash | rebase, default merge), MERGE_TRAIN_WORKDIR (the clone; default
cwd standalone / managed clone cache in the daemon), MERGE_TRAIN_LANES (comma-sep
repo@base allowlist; default every lane the hub reports),
MERGE_TRAIN_WORKER_IDENTITIES (worker-class gh logins, default detached-cody),
MERGE_TRAIN_EVENTS=0 (kill switch for the event-driven wake leg), MERGE_TRAIN_ENABLED=0
(kill switch for the daemon leg). Flags: --lane <repo>@<base>
(drive one lane only), --once, --interval <secs>, --method <m>. bridge doctor
gates the prerequisites (gh present + authed + repo scope, git — each failure names
the fix); the executor re-probes the cheap ones every start. It merges as whatever
identity that gh is authed as — the identity gate refuses the write legs unless that
is the worker-class credential per BR#3181, never a personal one.
Dedicated merge node (role: train, BR#4489): a normal gateway in a named mode —
set "role": "train" in the config (~/.bridge/config.json) or BRIDGE_ROLE=train in
the environment, and the daemon skips task dispatch entirely while the connection and
merge-train legs keep running. This is the redundancy model from the ADR: run N
capable gateways (or one dedicated train node) — merges never move server-side. Default
(absent role) is the unchanged opportunistic gateway.
One tick: preflight → train_status → per lane: CAS-lease → reconcile from live
GitHub (never trust a prior holder's picture) → awaiting-CI poll (train.ci; the hub
lands, stops or pends) → else the derived queue is walked in order (BR#3935): a
permanently unlandable entry — a CLOSED PR, a leg with no PR stamp, no unlanded leg on
this lane — is logged and the walk advances to the next entry in the same tick (the
card stays queued for the humans; the lane never starves behind it), while a transient
no-op — mergeability still computing, unsettled checks, no verdict — still blocks; the
first drivable entry's first unlanded leg on this lane
(BR#3659: a multi-PR card rides leg by leg) → decideTrainGate → act: merge (gh) →
train:merged → next tick polls CI on the merge commit. Every fact crosses the
executor's fact boundary normalized (BR#3714): gh's GraphQL mergeable is the enum
string "MERGEABLE"|"CONFLICTING"|"UNKNOWN" — never a boolean — and
mergeStateStatus is UPPERCASE; prFacts() maps them to the boolean|null/lowercase
the shared gate compares. Fed raw, the strings satisfied neither mergeable !== true
nor === "clean", and every gated candidate bounced (genuinely mergeable PRs
included) as "unmergeable — CONFLICTING".
- A lane card per
repo@base— created on demand (train.lane), taggedmerge-train+infrastructure, never claimable by agents, blocked at birth with the honest reasonmerge-train: awaiting an available gateway(merges happen when your machines are on). The card's blocked reason is recomputed by the hub from the card's own meta —idle/leased by X/attempting <card> (gate)(a queue head is in the gate; the tooltip carries the last ledger action) /settling on <sha> (PR #N)(naming the next head) /awaiting bypass merge — PR #N by <card>(a head blocked on branch protection a machine cannot satisfy — review/required checks; a human merge is the unblock and it self-clears when the PR lands) /STOPPED — … — culprit <card>/retired(a done/superseded lane card is never ticked again, BR#4977) /ready — last ticked by X Nm ago(a merge-capable gateway ticked inside the 15-minute window — the train will take work the moment it is queued) /awaiting an available gateway— so the board always shows the lane's truth, and an empty queue never hides which machine is driving the train. - Lane cards are close-protected + terminal cards never bind (BR#5061): the lane
card IS the lane — closing one done/superseded retires a live subsystem. The
2026-10-29 outage: a duty session closed the
state-vector/bridge@mainlane card done as a close-reflex, the find-or-create kept binding the terminal card every tick, and the lane could not self-heal for hours. Two laws now hold: (1) the transition gate'sinfrastructure-close-protectionrule refuses done/superseded on any card carryingmeta.laneor taggedinfrastructureunless the actor is the owner, a human (web:/human:), or the machinery (system:) — a duty reaching this edge gets a named rejection and records done-with-findings on ITS OWN card instead; and (2) the lane lookup skips terminal cards: find-or-create prefers the newest non-terminal card carrying the lane key and follows thesupersedesedge to a pre-registered reseed — with the volatile in-flight state (an awaiting-CI merge, a line stop, a bypass wait) migrating to the live card once, loudly (lane_rebound), so an in-flight merge finishes its landing close across the rebind. With NO successor the lane is deliberately retired and the BR#4977 refusal holds (every op refuseslane retired; re-open the card or register a reseed to re-arm — BR#5061's spec'd create-fresh is superseded by the accepted BR#4977 decision on that path).train.statusshows ONE row per lane — a retired card with a live successor is filtered as history, a retired card with no successor stays visiblyretired(BR#4977) — and the hub's lane sweeps skip retired cards; the starvation clock never inits from epoch: a lane that has never recorded a merge-capable lease starts its starve clock at its own last status change (the bogus "starved 29888714m" alarm class is gone). - The lease is CAS with a 5-minute TTL: acquire → held across the whole queue
(
merge-train: leased by X — idlebetween items) → release. The hub stampsholder = the authenticated actor; the executor never supplies a holder, so a leaked token cannot forge another machine's lease. A lapsed lease is cleared by the hub's credential-free train-watch sweep and the next gateway reconciles from live GitHub — a crashed executor cannot wedge the lane. - Zombie eviction (BR#4129): every renewal carries proof-of-progress — the
holder's last ledger action and its age. On a non-empty queue, a renewal whose
action note is frozen past 10 minutes (
TRAIN_PROGRESS_STALE_MS) is DENIED and the lease expires immediately, so the next gateway's acquire wins within one tick; the lane card showszombie holder evicted — <holder> made no progress for Xm on a non-empty queueuntil a fresh holder takes over, and the eviction is audited (train:lease_evicted). A live holder never trips this: every legitimate tick on a non-empty queue either acts (a CI-poll note refreshes the action every tick) or releases the lease, and an empty queue always renews (idle holding is harmless). Legacy executors that send{}renewals carry no proof — treated as UNKNOWN (allowed + a loud audit line) while the deprecation window runs. - Starvation alarm (BR#4489): a lane whose queue is non-empty with no
merge-capable lease for >10 minutes cannot advance — the executor outage class
where gateways tick (bounces flow) but merges stand down. The hub stamps the episode
(
meta.lane.starved_since), broadcastslane.starvedon the WS push channel, marks the lanestarved: {since, mins}intrain_status(the trains panel renders it red), and files one time-sensitive ask per episode to the alarm sink — restart a capable gateway, merge the head by hand, or waive. Judged both ontrain.statusreads and proactively on the hub's alarm sweep, so a dead train is loud even with zero panel readers. Recovery is automatic: the first capable lease (or the queue draining) clears the starved state. - Lane lifecycle: a retired lane is never ticked again (BR#4977): a lane card
that is closed (
done/superseded) — a base branch deleted after a drill, or a lane deliberately retired — is frozen. Every lane-touching hub op refuses it (lane retired — … re-open the lane card to re-arm): no lease acquire/renew/ release, noready — last tickedstamp, no reason rewrite — the BR#4772 class where a card marked "never tick again" still showedready — last ticked by … 0m agoten minutes later.train_statusshows the lane visibly retired (retired: true, empty queue, the reason names the retirement) instead of a phantom-ready row. The executor honors it from both directions: it skips any lane the hub marks retired, and it probes the lane's base branch before acquiring — a definitive 404 (base deleted on the remote) skips the lane with no lease and no hub meta writes, while any transient gh failure fails open. The derived queue is base-scoped (same law): a queued card rides only the lane whose base matches its PR stamp (meta.pr.base, defaulting tomain) — a second lane on the same repo can never inherit another base's queue (the retired drill lane used to show@main's queue as its own). - Event-driven tick (BR#4806): when a lane's derived queue transitions empty →
non-empty, the hub broadcasts
lane.queued {repo, base, depth}on the push channel (SSE, the same transport aslane.starved— subscribe at/api/kb/ssewith a gateway token). A ready gateway runs ONE tick on arrival — the exact same CAS lease acquire, identity gate (BR#3181) and proof-of-progress renewal (BR#4129) as every tick — so work that queues right after a tick starts within seconds instead of waiting out the next 45s poll. The poll cadence stays as the fallback: a missed event degrades to today's behaviour, and push + poll firing together is safe by construction (the CAS lease serializes; the second acquirer stands down — pinned in test). - The queue is derived, never stored: every open PR on the lane is re-read from GitHub each tick, oldest first, and only the head of the queue may merge — one merge in flight per lane, and the lane HOLDS until main settles before the next head enters. Serialized is the point: no interleaved merges, no surprise rebases, every merge attributable to exactly one lane event.
- Back-pressure: main settles before the next head enters (BR#4804, cadence
amended by BR#4934) — after a merge, the lane's awaiting-CI phase judges the
check runs ON the merge commit: a lane with a configured
required_workflowsset settles exactly those; an unconfigured lane (the default) settles on every check run on the commit — build AND deploy — with a 2-minute settle dwell after the merge so slower-triggered deploys can register a check run. Anything unsettled (including a cancelled-only run) holds; anything red stops the line; only all-green (past the dwell) admits the next head. The old default — vacuous green for unconfigured lanes — merged back-to-back and every push cancelled the previous Deploy Dev, starving main. While settling, the lane reason readssettling on <sha> (PR #N): build/deploy in flight, attempting <card> next, heads held until main settles, and the panel's queue depth is the count of heads waiting behind the pressure valve. - Burst-drain settle: one settle per drain pass, not per merge (BR#4934,
decision BR#4932) — the per-merge settle stays the law for a not-clean
head, but a GATE-CLEAN head merges back-to-back behind the previous merge:
the executor drains every gate-clean head in one pass (up to
lane.burst_max, default 10,0= unlimited — it ridestrain_lanelane config alongsiderequired_workflows) and runs the full settle ONCE, on the pass's FINAL merge commit. The lane reason names the phase mid-drain (merging burst (N merged, cap M) — settle pending) and on settle (settling on <sha> after N merges (PR #N): build/deploy in flight, heads held until main settles); without burst context the BR#4804 strings (BR#4981 vocabulary included) are byte-identical. Red anywhere stops the line instantly (GH#2261) — a red fed mid-drain never waits for the settle. While the settle wait runs, the executor uses it as a rebase window: one machine-cleanupdate-branchper tick on a behind, verdict-covered queued head (a clean trial merge rides with it; a dirty one conflict-bounces; carries append contiguously viatrain.carry— the minimal primitive, superseded by L1.4's carry law when it lands). On a GREEN settle the pass's intermediate parked cards close on executor-fed git ancestry (ancestors_of_settledviatrain.ci— their runs were cancelled-by-next-push, and the hub holds no VCS credentials, so the ancestry is the evidence; zero hub GitHub calls); the final head closes per BR#4804 unchanged. On a RED settle the hub pre-stampsstop.bisectand the executor probes the pass's intermediate merge shas oldest→newest (one step per tick viatrain.bisect, re-running the stamped checks): the oldest RED probe names the ONE culprit — it bounces with a fix-forward brief + the BR#4068 boost while the wrongly stop-bounced final card re-parks to close on the next green settle; probes that stall past the CI bound (or leave nothing re-runnable) stamp the walk INCONCLUSIVE and the pass bounces oldest-first. - The landed close is guarded — a green verdict can never wedge the lane
(BR#4753): when the all-green verdict fires the landed close and the close itself
is refused (a gate rejection — e.g. done-open-children — or a storage fault), the
old code raw-threw, silently, every tick: the lane sat
awaiting-cifail-open past the fail-closed bound (which was only ever consulted on the pending branch) and the queue starved behind it. Now every failure is counted on the lane's in-flight record (the lane reason names it:landed close FAILED N× (<class>)) and resolved by the retry ladder: a semantic gate rejection close-bounces the culprit card immediately (retrying cannot succeed — the bounce brief carries the named gate error and the repair contract: the PR is already merged and main green, so resolve exactly the named precondition, then close citing the merge commit — no re-merge, no re-verify); anything else retries ≤3 ticks, then — or past the bound, whichever first — bounces fail-closed withbounce_bound. The bounced card is boosted to priority 0 (one mechanical leg from landed) and leaves the derived queue, so the lane idles and the next head enters; a fault after thedoneactually landed repairs to a clean landed on the next tick instead of bouncing a closed card. One deduped ask per episode rides the alarm sink (train_alarm_sink_id, default BR#3124) — the failure used to be invisible.
decideTrainGate composes the standing decideMergeAction 5c gate (mergeable, clean,
every commit GitHub-verified, base green, head checks green) plus verdict coverage:
the accepted verify verdict must stamp the exact head, or arrive by a contiguous
machine-clean carry chain:
- A git failure is never a verdict (BR#3935): the executor fetches the PR head
explicitly (
refs/pull/<n>/head) before any localmerge-base/merge-treenames it — a head authored on another box is a bad object locally, andmerge-treeexits 1 for a bad object, the same code as a real conflict. Both inputs are verified commit objects first; any git failure fails the tick closed with the stderr, and a bounce's conflict-file list is exactly the conflicted files (--no-messageskeeps git's prose out of it). The old behavior — a 128 folded intobehind=trueand a bounce with an empty conflict list — is dead. - Carry law (BR#3516 §5): the base advanced and the executor's
gh pr update-branchlands machine-clean → the accepted verdict CARRIES (the hub appends the carry record ontrain:merged). A conflict resolved by hand means authored code changed under the verdict → the verdict is INVALID at the new head: the executor enumerates the conflicting files (git merge-tree),train:bouncedlists them on the PR's card, the card re-enters review, and the train re-queues it at the BACK once a fresh verify accepts the new head. A bounce and its re-queue are machine-managed round trips: a train park — the train's ownmerge_trainflag, or an agent re-queue following the verify contract's canonicalmerge-train: queuednote — never spawns an unblock child. The train is the unblock path (BR#3516 §2); a spawned child would only be superseded by the next bounce, with the re-park after it flood-guarded on the stale stamp — one breach card per bounce cycle (specimen BR#3990 → breach BR#4135). - Bounced cards cannot starve (BR#4068) — a bounced card is the most valuable work
on the board: verified once, one leg from landed. So the bounce itself auto-boosts
the card to priority 0 — the same
boostedstamp the human ▲ boost writes, read by the claim ranking, the train rank and the Backlog; it decays only when the card re-enters the train (re-queue), never auto-expiring mid-repair. The review ask is RETIRED (BR#4962, per accepted BR#4960 — ask-as-visibility is gone): a coverage bounce (an authored push moved the head past the accepted verdict — a lapsed verdict, not a defect) parks the cardblockedin place under the typed reasonAwaiting review (verdict BR#N, anchored at head <9hex>)with the typedpr_mergedawait_event preserved andmerge_train.awaitingstamped, so the trains panel renders it as an◉ awaiting <age>chip — amber past thetrain.awaiting_ttl_hwindow (legacytrain.review_ask_ttl_h), red past TTL + 2h grace, a lapsed park still listed (visibility without interruption). Zero asks and zero lane digests: the parked card in the backlog IS the human leg, and the human force-reviews it from the card —approvere-anchors the accepted verdict at the parked head (a carry leg per unlanded PR, via "force-review"), clears the park, decays the boost and re-queues the train's own flow (the merge + CI settle still land it);request_changesbounces impl exactly like the train's own bounce (repair leg, the reviewer note rides the brief, fresh verify on re-entry — the parked verdict is NOT carried across a requested change). Gate/conflict bounces keep the impl repair leg with no ask legs either — the repair leg re-enters review through the normal in_review handoff. Force-review is web/human-only (the route refuses gateway bearers with 403); the stop-the-line and signing-environment asks remain the only genuine-decision asks on a lane. mergeStateStatus=behindpre-clears at the TRAIN gate only (BR#3727): behind is exactly "base advanced, branch still mergeable" — the state the carry law was written for, which the shareddecideMergeAction(clean/unstable, untouched) bounces because the sweep has no update-branch authority. The train gate pre-clears it so the executor reaches its owngh pr update-branch: behind + a verdict-covered head + a clean trial merge → machine-clean update, verdict CARRIES, and the train merges when the new head's checks settle. Conflicts still bounce with the file list. The tick right after an update-branch readsblockedwhile strict branch protection re-runs the required checks on the new head — pending, not a defect: the gate WAITS on it (red head checks note; blocked with green checks — protection wants reviews — still bounces honestly).- UNKNOWN mergeability polls, never bounces (BR#4685): GitHub computes mergeability
asynchronously — a read right after a base merge yields
mergeStateStatus=null/UNKNOWN, andmergeablerides the same recomputation, so an answer is definitive only when the state names itself. The executor backoff-polls inside the tick (15s/30s/60s,MERGE_TRAIN_POLL_SECSoverrides) — bounded by the lane lease, renewed around every wait — until GitHub answers, and the shared gate WAITS on an unanswered state: never a bounce, and never a merge on an unconfirmed state (the old null-state fall-through is closed). Bounce only on definitive DIRTY/CONFLICTING. Receipt: the 2026-10-28 ledger, where PRs #279/#281 bounced 28× withmerge_state_status:null— every "CONFLICTING" row of that loop trial-merged CLEAN (a stale read, not a conflict). - Mechanical defects bounce with the exact defect named — conflicts, red base, red head, unsigned commits, a head the verdict no longer covers — the same law the verify round enforces (rule 5c), now executed by the train. Human-leg defects (signing-environment, external mirrors) file ONE class ask per episode, never one per PR.
- Every bounce names its KIND (BR#4066) — and the kind picks the brief's copy and
the next owner it cites. Coverage: the head moved past the accepted verify
verdict (an authored push; no conflicts exist) → the brief says so and hands the card
to the verify pool to re-verify at the new head — no conflict language, no file list.
Conflict: trial-merge conflicts → the enumerated file list rides (a conflict
bounce without it is refused outright — the "(no file list supplied)" dead-end cannot
render), and the impl repair leg owns the resolve. Gate: a mechanical defect
(
mergeStateStatus, red head checks) → the defect is quoted verbatim and impl fixes the named defect. The executor trial-merges only a conflict-class bounce — a coverage or gate bounce never runs a trial rebase, so a stalemergeable=falseread (BR#3712 class) lands as a gate bounce naming the stale state, never as a file-less conflict. - Multi-PR cards are legs, gated per leg (BR#3659): a card queued with several PRs
is a list of legs (
meta.prs;meta.prstays the first leg for legacy readers). Coverage is judged per leg (verdictCoversHeadLeg— one leg's stamp/carry never covers another), the gate bounce names ITS leg's stamp, and the card appears in exactly the lanes that own one of its UNLANDED legs. A partial landing keeps the card queued (merge-train: queued — waiting on N leg(s): repo#n, …); the final close cites every leg's merge commit.
train.status (GET /train mirrors it) · train.lease (acquire / renew / release) ·
train.event (merged | bounced | stopped | landed) · train.ci (executor-fed check
runs on the merge commit) · train.lane (ensure / configure) · train.history (the
train_events ledger — provenance serving). The executor never self-certifies:
train:landed re-classifies the fed check runs and REJECTS a non-green report outright
("post train.ci instead and let the hub decide").
// admin.settings_set — lane config seeds the lane card at birth; the
// operator/executor refreshes it in place via train.lane afterwards
"train_lane": {
"state-vector/bridge@main": {
"required_workflows": ["Test", "CLI build (packages/cli)"],
"ci_typical_minutes": 20
}
},
"train_alarm_sink_id": "BR#3124" // stop-the-line asks land here (default BR#3124)required_workflows names the checks that must be green to land (the same checks the
main-green gate ruleset enforces); ci_typical_minutes paces the fail-closed CI bound
(unknown past 3× typical classifies red — the train never waits forever). Lane identity
is repo@base, so one repo can run several lanes (e.g. main and a release branch).
Every arm of the knowledge channel — the DO fetch entry, the worker's
/api/kb/*, /api/kb-machine/*, /api/kb/admin/* proxies, and /mcp — is a
catch-all: a storage-layer fault answers typed JSON with
result_class: "infra_storage_fault" (HTTP 503), never an uncaught throw, a CF
HTML error page, or a mislabeled 401 unauthorized. Before BR#4212 (incident
BR#4117) one DO storage fault took the whole entry down uncaught: the runtime
served HTML 500s where JSON acks belong, writes were silently lost, and
/mcp's catch relabeled infrastructure failures as "unknown or revoked token"
— a storage storm read as an auth storm. The classification law since:
infra faults retry (503), auth rejections 401, and the two never masquerade
as each other. Only the three genuine identify rejections (missing bearer,
unknown auth token, auth token revoked) produce a 401.
The third class — the hub refusing — is not infra at all (BR#4390). A
machine gate rejection (an illegal lifecycle edge, done-open-children,
blocked-needs-typed-await, a claim race, …) is a deterministic answer:
the DO stamps it result_class: "gate_rejected" and answers HTTP 400
carrying the rule name ([rule: …]), and the MCP layer surfaces it as a
tool error with that message intact. Before BR#4390 an untyped 400
rejection fell into the infra fallback and came back as
503 "kb temporarily unavailable — retry" — so refusals that could never
succeed on retry (the BR#4068/BR#3822 close attempts) read as an outage and
burned dozens of futile retries. The classification law, complete:
5xx / typed infra_storage_fault / unclassified throws → 503 infra
(retry the channel); 401 → the three genuine auth rejections; 400 → the
hub refusing — read the rule, act on it, never retry blind.
The watchdog's view is machine-checkable via GET /stats (or
POST stats): the response carries a channel block — per-route ok/err
counters with write_ok_rate over the write ops, entry_faults (which
storage stage faulted, how often, how recently), ws_send_fail (failed
fanout sends, the in-DO proxy for clientDisconnected), and last_flush (the
alarm tick's persisted snapshot — counters reset at isolate boot,
window_started_ts says when). "Channel down" is thus a readable predicate
(error rate + fault recency), not inferred from silence — phantom stalls are
distinguishable from real ones. The DO alarm failure policy: degrade + retry
— a faulted re-arm never throws out of alarm() (a throwing alarm loses its
schedule entirely); the next fetch entry re-arms via the null-or-stale guard,
and the sweep rides the 60s alarm retry chain.
The hub is coordination infrastructure — it gets the same treatment the product gets:
its load-bearing paths get a synthetic end-to-end canary, run daily, red alarm to
the alarm sink. bridge canary is a single cron-friendly lifecycle pass against the
deployed hub (same MCP surface as every agent, same BRIDGE_URL/BRIDGE_TOKEN):
export BRIDGE_URL=https://bridge.<your-subdomain>.workers.dev
export BRIDGE_TOKEN=<token from the Connect tab>
~/.local/bin/bridge canary # one full pass (typ. 15–25 min; worst case ~65)
# cron/launchd daily — exit 0 = green, 1 = red (filed alarm), 2 = config fatalFour legs, each named for a failure class actually observed:
- Duty spawn → dispatch → delivery — a qualified synthetic task must be claimed by a runner within 10 minutes and deliver its marker to the board within 10 more. Catches the "result delivered late/lost" class (two observed in a single week).
- Flip-on-event — a synthetic card parks on a typed
{event:"time"}await; the unblock proxy sweep must flip it within two sweep cycles, no human action. Catches the stale-ledger class (BR#3620 family: resolutions hidden for a day). Thepr_merged/run_successflavors of the same mechanism fire daily on the real train; the canary uses the timer flavor because the hub holds no GitHub credentials by design (BR#3516) — non-lane GitHub facts are never fed to it. - Queue visibility — a fixture carrying the BR#4079 canonical stamp
(
merge_train.pr>0+merge-train:reason,blocked) must appear in the derived train queue, then vanish the moment it retires. Catches the clobbered-stamp class (BR#3912). The fixture cites an obviously-fake PR (999901); if a train tick ever lands on it, the executor'sgh pr viewfails harmlessly before any gate — one lost tick, zero board writes. - Reconcile accounting — BR#4170's report must run green (every PR accounted). Armed but skipping until that lane lands its report surface; a missing dependency is not a red.
Red path: one deduped ask per failing leg class, bound to the alarm sink
(train_alarm_sink_id, default BR#3124) — the same child-ask pattern the train's
stop-the-line uses, so a stuck class pages once, not daily. Green path: a
[hub-canary] progress artefact stamps every run; 48 hours of silence on that feed
is the canary's own liveness alarm (a canary that stops running must be observable
too).
Fixtures self-clean by law: tagged [canary], retired to blocked with a 30-day
typed timer (inert: never fires, never claimable, never queued). Task→superseded is
not a client-legal edge (core.ts LIFECYCLES), so retirement — not deletion — keeps the
board free of fixture accumulation without loosening the state machine. At most two
inert blocked fixtures accumulate per run.
The merge train accounts for PRs it knows about. The reconcile loop closes the gap: every open reviewed PR must, at all times, be one of exactly three things on the board — bound to a card, ignored with a recorded reason, or in-flight on a spawned duty. Anything else past the actionable age is an ALARM to the sink, never a silence (the #2466 class: a session died post-open and an APPROVED+MERGEABLE PR sat invisible for 34 hours, outside every stamp-gated mechanism).
Division of labour is the credential split (BR#3499/BR#3507): the gateway daemon runs the
read-only gh pr list leg and feeds raw observations via reconcile_report; the hub
runs the join (reconcile.ts, pure), decides, and performs every board mutation. The
daemon never writes the board; the hub never touches GitHub. A report per repo per cycle:
- bound — an open card rides the PR (
meta.pr/ ameta.prsleg); queued cards are the train's business and are never re-spawned at. - ignored-with-reason —
reconcile_ignore {repo, pr, reason}rules a PR off the board on the record; an ignore without a reason is refused (an ignore IS a reason). - in-flight / spawn — an orphan APPROVED+MERGEABLE PR past
verify_age_hgets a binding card (meta.pr projection, session-less) plus the standard 5c verify duty as its child — the same template the normal in_review spawn uses, so ACCEPT queues the card exactly as BR#4079 prescribes. Reconcile creates tracking cards and standard duties only; it never queues, stamps, or merges. - destination-blocked (BLOCKED/BEHIND) binds WITHOUT a verify duty — a verify would bounce on mergeability by construction; the duty comes when a later cycle sees CLEAN.
- dead duty ⇒ ALARM: the duty ledger (
reconcile_duties) holds one row per repo+pr+kind EVER, so a duty that ended without binding its PR alarms instead of silently respawning over the corpse.
Reverse reconcile guards the other direction: a bound card whose PR vanishes from the
open list starts a missing clock; two consecutive misses with a provenance row close the
zombie binding via the machine proxy (BR#3287 — the train stamped the merge); with NO
provenance it spawns a ruling duty instead — someone rules, never silence. Standing
merge-lane cards are exempt from ALL of it (BR#5151, the BR#5050 incident): a lane card
bound to a PR — even with a provenance row stamped on it and past the two-miss guard — is
accounted lane-exempt and never closed, never dutied, never clocked; the train's settle
path is the only lane writer. The close-protection gate matches: owner identity is no
longer a close path for a standing card (the reconcile daemon's actor WAS the lane's
owner) — humans (web:/human:) and the machinery (system:) only. And train:merged
is stateful about merges: a PR merges exactly once, so an identical re-report collapses
to an ack (crash-recovery is free) while a different merge sha for a recorded merge is a
named protocol reject — never a silent provenance overwrite.
Knobs: reconcile.enabled / RECONCILE_ENABLED (default on),
reconcile.interval_secs / RECONCILE_INTERVAL_SECS (default 1800, clamped 300..21600),
reconcile.repos (CSV, default both bridge repos), reconcile.verify_age_h (2),
reconcile.disposition_age_h (24), reconcile_alarm_sink_id (default BR#3124).
reconcile_status is the operator view: settings, the accounting lines (the canary —
N open reviewed / N bound / N ignored-with-reason / N in-flight — N accounted, ALARM
tail on failure), the duty ledger, ignores, and the missing clock.
Every merge is attributable (BR#3287) — and under BR#3516 the stamping is
ledger-native: there is no provenance sweep and no backfill job; the train's
train:merged events stamp the chain as each merge lands. Per merge the ledger
records: merge sha, head sha, the linked task (head branch bridge/BR-<id> is the
authoritative join key, PR text the fallback), the accepted verify verdict covering
that head (stamp or carry chain), the owning agent session, and the signer class
(detached-cody / signed / partially-signed / unsigned) — plus the carry record
when the verdict rode a machine-clean base update (BR#3516 §5).
- Storage of record is the board (decision BR#3291): the task card carries
meta.provenance(rendered as a provenance strip in TaskDetail, and on the board's Top rows as a PR link + verifier badge for in-flight work — BR#3307), the DO keeps aprovenanceindex, andGET /api/kb/provenance?sha=<7-40 hex>resolves sha → PR → task → verdict → session (POST /provenanceand the MCP surface mirror it for machine consumers). Unknown sha = honest 404 "no bridge provenance"; merges with no bridge task are recorded as absence — itself a signal. The verdict leg is honest too (BR#3307): when no accepted decision covers the head sha,verdict_idis recordednull— never an arbitrary stale verdict. - Absence renders explicitly (BR#3287 spec item 3): a Top row with nothing stamped carries a muted "no bridge provenance" chip — untracked merge, non-bridge work, or not yet trained. Absence is a rendered state, never silence.
- Consumers: the stop-the-line culprit bounce (the ask names the just-merged card —
the guilty chain is on the ask, one lookup away), humans auditing who landed what,
and any agent needing receipts.
train.historyserves the raw ledger.
Work leaks at its END, not its middle: a card parked on a machine-checkable exit condition nobody re-checks (BR#3129 — the proxy flipped and sat ~20h), a done card whose final "close GH #N" leg has no owner (GH #2261), a done card claiming a close that never happened (BR#3220). The closure tier closes those leaks with four hub mechanisms:
- Unblock-proxy watch (a):
unblockProxySweep(paced byunblock_proxy_sweep_minutes, default 15,0= off) re-evaluates every blocked card'sunblock_conditionon each pass. The parser (packages/worker/src/kb/closure.ts) extracts machine-checkable atoms —pr_merged(PR + url/number),br_pr_merged(a "BR#N's fix PR is MERGED/merges" condition — with a repo URL, or bare and resolved against the default repo),issue_closed,br_done,br_answered("BR#N answered" — the ask/question card lefttodo),run_success,pr_checks_green— and cuts the DUTY clauses ("then a fresh session closes…", "Then: …") so instruction text never parses as a precondition. Conservatism law: machine-looking text that yields no atom stamps the verdictunparseable(never auto-flips); atoms with no resolvable repo stampunresolvable. Since BR#3516 the a genuine false→true transition still spawns exactly once. hub holds no VCS credentials and never reads GitHub live: unknown GitHub state (issue_closed, merges with no ledger row) stampsunresolvableand spawns nothing (run_success/pr_checks_greenparks are probe-backed instead — BR#4658, below) — but three fact classes the hub owns outright still verify and auto-spawn the unblock duty with the receipts embedded (a trigger, never an authority: the spawned duty re-verifies first-hand): board-state atomsbr_doneandbr_answered(BR#3620 — the answered-ask class, so a card parked on a human ruling flips the moment the ruling lands); and merge atoms backed by the ledger (BR#3620):pr_merged/br_pr_mergedevaluate from the train-stamped provenance table (BR#3287 —train.event merged), receipts citing the stamped sha + signer, no credentials needed. Worker-executor verification probes (BR#4658) give the CI atoms a machine closer: forrun_success/pr_checks_greenparks the sweep enqueues a verification probe (deduped per atom across waiting cards, attempt-capped with backoff pacing), a daemon executor (the desktop app /bridgerunner, beside the merge train, read-onlygh) pollsproxy.probesand feeds each verdict throughproxy.verify; the HUB decides — a satisfied verdict at the awaited head stamps aproxy_verifiedevent carrying the GitHub-URL receipt of record and re-enters the standard satisfied-proxy path (BR#4282 close/spawn, flood laws intact); a success at the WRONG head is a conclusive NO and the park stands (BR#3409 fail-closed); transient gh failures back off, and an exhausted probe returns the park to a loudunresolvable— visible, never a silent loop. Legacy prose cards the sweep cannot fully read run the disposition ladder (BR#4416 — the machine resolves what the card's own state can prove, a human is asked only for genuine ambiguity, ONE class ask per day):recoverProseLegsrecovers the legs the card's meta already carries — an existing migration ask (BR#3578,blocked.await_migration_ask), meta/merge_trainPR stamps, a terminal root blocker — and an ask leg that is already answered flips the card (BR#3620's answered-ask exit, ladder-native: the ask was recorded, answered, and the card never moved — dead). A leg that holds fires the exit with no ask; a recoverable typed leg re-types the park in place; a park with no leg at all releases to todo for re-scope; a no-legged runner/human-owned card collects into the ONE per-day class ask. - Terminal close-leg ownership (b): a new gate rule
done-close-legsreads the "## Done when" section + the done note; every terminal "close GH #N" intent (or receipt-shaped claim like "GH#N fully closed") must carry a closure receipt or be owned by a CLOSED close-duty child. A rejected done auto-derives that child (Close-leg duty: …, flood-guarded per legs-shape) so the very next attempt has an owner — no orphan terminal legs. - Done-gate closure receipts (c): the receipt contract is
state=closed + closing-comment id (or the comment URL) + guard run id (or n/a). Intent language ("will close", "fully closed", "close-guard blocked") is not a receipt. The done-time stamp (meta.close_leg_audit) records the claim; the LIVE GitHub re-verification sweep (closureAuditSweep) was deleted under BR#3516 — the hub no longer reads GitHub — so a false claim (the BR#3220 class — the board reporting a closed issue that wasn't) surfaces through the close-duty child's first-hand re-verification instead of a hub sweep; the gate still rejects an unreceipted done and auto-derives the close-leg duty exactly as before. - Duplicate-executor fence (d): orient exposes the executor lease (
executor_session) on open cards and my_tasks; a state-changing op by a named session other than the lease holder while the holder is alive (<10m) is auditedsystem:fence / duplicate_executor— observability, never a block. Prompt law backs it: a lost claim race means you are not the executor; stand down (rule 5d + protocol card 7). Hub/auto-spawned duties start session-less — the first real session to claim them stamps the lease.
Drills: packages/worker/test/closure.test.ts (pure parsers pinned to verbatim real card texts —
plus the train suites — test/merge-train.test.ts (the pure gate, carry law, including BR#3423's stale-ledger specimen, BR#3620), test/closure-sweep.test.ts (real DO: GitHub atoms stamp unresolvable + unparseable honesty; the BR#3620 flips — answered-ask and train-stamped-merge cards flip, park-round close receipts; rejected done → close-duty child → receipted done unblocks the parent; duplicate-executor fence audit + lease), plus the train suites — test/merge-train.test.ts (the pure gate, carry law,
queue and classification) and test/train-hub.test.ts (real-DO train drills: lease races,
landed / stopped / bounced flows, the stop-the-line ask).
admin.unblock_proxy_sweep drills on demand.
The specimen: a watchdog rollup stamped a card child_of itself at creation; the done gate
then counted the card as its own open child forever — every →done rejected "still has 1 open
child" with no agent-repairable exit (MCP has no unlink), and answered asks kept resuming work
onto runners that had already finished. Four laws close the class:
- Self-edges are unrepresentable (a): the link API rejects
src == dstfor EVERY relation kind — loud, naming the self-edge class — so no writer (agent, rollup, future feature) can stamp a card as its own child ever again. The non-API write path is covered too: areplacement_idself-supersede is refused. Type errors no longer mask the honest reason — the self-edge check runs before relation validation. - The impossible state is invisible (b): a one-time migration (
self_edges_v1, settings-stamped, idempotent across boots) deleted the existing self-edges (per-edge audited), and every gate reader excludessrc = dstdefensively — a gate must never read state that cannot exist. - The agent-executable exit (c): a card the done-open-children gate refuses because a child
is genuinely unclosable (the self-edge class, or an orphan that cannot be closed first) closes
with a force-close-with-reason in the done note —
force-close-children: BR#12, BR#13 — why these children cannot be closed first— EVERY open child named (a partial list is refused by name), every named child audited as closed-by-force (meta.force_close+ version note). Loud by construction; never a silent path. - Answers resume live holders only (d): the ask contract above — an answered ask resumes a holder with a live signal inside the stale window; a dead holder is released to the pool with a loud note, never resumed onto a finished runner.
Drill: packages/worker/test/br3819-self-edge.test.ts (real DO: per-relation self-edge
rejection, migration stamp, partial/complete force-close, live-resume vs dead-release).
- Open the hub URL → Board (live fleet), For Me (proposed decisions awaiting your accept/reject — each shows the agent consensus tally and the reasons), Knowledge (search), Discussions (question threads)
- Reports tab (BR#5126): high-resolution delivery metrics — tasks closed per bucket (hour/day/week over 1d–365d windows, avg task/h) and PR throughput (merged + gate bounces per bucket, avg PR/day) from the train ledger, drawn as full-width charts with gridlines, axes and hover detail. Served by
GET /api/kb/metrics/reports(machine-plane friendly); the dashboard's compact throughput cell (BR#2937) stays on Top. - Every card has a stable URL (BR#3990):
/card/BR-3990deep-links straight to that card's detail panel, and opening or closing a card in the Board keeps the URL in sync — any card view is a shareable link (browser-back steps back out). The dash form is canonical (BR#3990→/card/BR-3990); the route also acceptsBR%233990and the bare number. The same URLs ride the MCP payloads: artefact rows returned byorient,search_knowledge,get_artefact(+see_also),related_artefactsandaskscarryurl(ask rows alsoartefact_urlfor their bound card), built from the hub's canonical origin — theCANONICAL_ORIGINvar (defaulthttps://hub.fleetbridge.run), regardless of which ingress served the request, so the workers.dev fallback never leaks into emitted links (BR#4695). An agent citing a card can hand you a link that opens it. - Create work:
bridge create_artefactvia MCP, or ask an agent to — tasks land intodoand the runner picks them up - Boost: the Backlog tab ranks work by pickup order;
▲ boosttakes a task and all its open children to priority 0, ahead of unblock duty. Boost cascades server-side and is audited;▼ unboostremoves it. - Progress gauges persist until updated: a
log_progresspost that omits gauge fields (completion_pct,confidence_pct,risk_pct,context_pct) preserves the previous gauges on the card — gauge-less posts (heartbeats, relay lines, auto-notes) never clobber them, and the freshest gauge-bearing call wins. - The watchdog keeps states moving: silent agents are released back to the pool, an
in_reviewpark pastreview_stale_minutes(default 360) with no verifier running bounces and releases too, and gate-rejection blocks are reaped after 24h. Unblock tasks close directly with their verdict — they are never reviewed. A silent task whose trail proves landed side effects (GitHub PR/commit receipts, or the runner's owncompleted_result_lostmarker) is released ascompleted_result_lost, not abandoned: the run happened and the report was lost between worker and hub. The class never penalizes the agent's behaviour score — the delivery channel is flagged instead (BR#3352). Silence never destroys a machine wait (BR#4067): a card whose meta carries a live wait — a merge-train queue slot (queued/awaiting-ci/bouncedon a live PR leg), a typedawait_event, or a bound ask — is parkedblocked, never released to todo, by every release path (stall sweep, review-stall sweep, lapsed subprocess, reaper reassign, and the machine-actor backstop in the transition gate). The park names the silence in its reason and preserves the wait meta intact — the typed proxy (e.g.pr_merged) still owns the exit, so the proxy watch flips the card when the wait resolves. A hold parks with result classproxy_wait_hold, which is exempt from abandonment scoring — holding a wait is neither a lost run nor a dump. A dispatched run also gets grace: the claiming session's freshest hub ping counts as a liveness signal, so a run that is plausibly still in flight is not swept while quiet. The bounce re-entry is the one release carve-out (BR#4494): a train bounce re-opens its cardin_progresswith the prior executor session — usually dead — andin_progressis not claimable, so a bounced card whose re-entry sits silent past the stale window is demoted to a claimable todo (bounce brief + boost intact, result classbounce_reentry_demote, exempt from abandonment scoring) instead of re-parked; the carve-out is scoped tobounced+in_progress—queued/awaiting-ciand parked-blocked cards keep the full hold. Rework-loop escalation is unchanged: a held card's attempt counter keeps counting, so a card that cycles still reaches the human-ask park at 5 attempts. Rework-loop escalation is unchanged: a held card's attempt counter keeps counting, so a card that cycles still trips the churn redirect at 5 attempts — and that park is now typed, not prose: a 24htimecooldown is the machine exit (the sweep reads the atom; on fire the duty re-checks and a proven release resets the counter; BR#4248). - Blocker rollup: when ≥3 blocked cards share one machine-detectable root cause, the watchdog sweep auto-creates ONE boosted
ROOT BLOCKERtask — investigate → fix → one approval — and every queued card points at it (meta.root_blocker+ achild_oflink, so the board shows the tree and the boost cascades to all of them). Today's machine-detectable roots: agithub.com/.../pull/NURL in the card's unblock condition (the one unmerged PR everything queues behind) and red/broken/failing-mainlanguage (the never-merge-onto-red-main class). Idempotent: cards already pointing at an open root are left alone, re-runs create no duplicates. A root closes only by an agent or human with receipts that the root cause is gone — the sweep never auto-closes it, and a later cluster of the same root opens a NEW root rather than reusing a done one. - Blocked parks get a clock too (BR#2893): churn-guard parks (retry instructions stamped as blocks) and parks whose unblock condition references only closed asks drain back to the pool automatically; an honest park with no external gate ages out past
blocked_stale_hours(a setting, default 48). True external gates — dated checkpoints, GitHub issue/PR lanes, red-main — are exempt, and so is any park on a live machine wait (BR#4067): a typedawait_event, a merge-train queue slot, or a bound ask owns its own exit, and the drain never attempts one (attempting it only re-bred a refused release — and a breach card — every sweep tick). A card whose owner has had no live session forowner_stale_hours(default 4) loses the claim regardless of status. And a parent that has burnedUNBLOCK_SPAWN_CAP(3) unblock rounds stops spawning them and escalates to a human once. On the board, Top is the actionable fleet only; every parked card files in Backlog under its EXIT CONDITION — one group per typed await class (awaiting you · PR verification · PR merge (train) · CI · deploy · another task · GitHub issue · a date · green main · other), with churn parks under Timeout (release early if you disagree) and a live unblock duty shown as a chip on the row. - Anything needing a human lands in For Me (genuine decisions only — a merge-train coverage bounce parks the card Awaiting for a backlog force-review instead of filing an ask, BR#4960)
- For Me never jumps (BR#3457): acting on a proposed decision (accept, or reject — which routes to supersede-with-reason, the decision lifecycle's only exit for a verdict that doesn't hold) pins the card to the bottom of its section with an
actioning…chip and dead buttons; the card leaves the list only when a refetch confirms the server flipped it, and an error returns it to its slot with the reason inline. Server-side, one actionable verdict per target, ever: a newer verify-verdict for a target supersedes still-proposed older rounds at create, and the human's accept/reject sweeps any leftover proposed sibling (legacy stacked rounds included) — accepting a stacked group removes exactly one verdict and the card can never regrow. - Breach cards: when the task machinery loops (rejection storms, double-spawn attempts, invariant violations, version-log replays that disagree with stored state), the server folds the audit stream into one open
breach-tagged card per (task, class) on the Board — the loop's fingerprint and its sample event, ready to investigate. A closed card never re-arms its key: every card stamps a fold high-water (the newest event its adjudication covered — its raised group plus everything that coalesced while it was open), and a later fold trigger counts only events strictly above that mark, so already-adjudicated events can never resurrect as a duplicate card while they remain in the lookback (BR#3443); genuinely new events raise a fresh card. Not every rejection counts: claim races never do — both a raced double-claim (lifecycle-edge rejections) and a non-owner's attempt to release a claimed card (the ownership gate'srelease-needs-ownershiprejections, e.g. a dispatched session's rule-9 release on a runner-claimed task) classify asclaim_raceand are priced as noise, so no pile-up of them ever raises a breach card (BR#3337, BR#3429). The classifier matches the guard's structural pair (claimed by+only the owner), not a sentence literal — a wording edit to the gate message can no longer silently re-classify its rejections (PR #210's proven-unblock rewording did exactly that until BR#3611). Three release-protocol truths ride with it: a gateway may release its own attributed claim (claims are attributed — owner = the dispatched agent — so the raw gateway actor never matches the owner; the self-release exemption is token-derived and unforgeable, while a different gateway's release stays gated), and the runner only posts its "stale claim released" note when the release actually happened — a refused release stays noteless (the board never records a release that didn't happen) — and the stale-claim flow re-checks the live card before touching it: a release fires only while the card is stillin_progressand still attributed to this runner, so a card the watchdog already redirected (blocked) or another dispatcher already re-claimed is skipped outright (no rejection, no note, no re-claim), and the recovery re-claim carries a resolution note re-stamping the card's own blocked reason + unblock condition (BR#3823). The replay fold re-anchors across historical chain gaps (a row that doesn't chain is healed and, when it's the newest row, audited as achain_break) — the breach fires when the fold's end state disagrees with the stored status, not forever after on ancient damage (BR#3322). Known gates get named classes: the BR#3578 fail-closed typed-await park gates classify asgate:blocked-needs-typed-await(a park with no grammar-valid await_event) andgate:malformed-await-event(an await_event that fails the closed grammar — e.g. arun_idsent as a number) instead of folding into theunclassifiedbucket (BR#3738), and the verdict-done receipts gate classifies asgate:verdict-receipts— a rejection carrying the audited[rule: <name>]tag classifies by its rule name FIRST (the gate's own message embeds "PR link", which the older message-stem ladder mis-folded asgate:review-needs-pr, leaving the receipts class unreachable for its own gate; specimens BR#3735, BR#4291), with the stems kept as the fallback for suffix-less legacy reasons (BR#4295). The BR#4067 release-hold backstop classifies its refusals too: a machine silence- or owner-death release correctly held by a typed wait or a train queue slot is benign by design — the proxy watch / the answer / the train owns the exit — so its rejections classify asrelease:held-typed-await/release:held-train-queueinstead of piling intounclassified(specimens TASK-016/BR#4563, BR#3727/BR#4573). And the blocked-park drain's ask-ref scan is word-bounded, so an unblock condition naming a task id (TASK-016) no longer yields the phantom refASK-016that made the drain re-attempt a refused release every sweep tick (BR#4563). The receipts gate's rejection and every unblock-duty spawn template now teach the accepted note shape — any URL, or one of the verdict tokens (PR/merged/passed/unblocked/still blocked) — so a duty holder closing with honest findings prose passes on the first try instead of burning rejections (each rejection is a breach event) reverse-engineering the matcher from source (BR#4295). And the bootstrap teaches every dispatched session the retry law that keeps the loops from forming at all (rule 4c + a mirrored PROTOCOL CARD clause, BR#4474): a 5xx /infra_storage_faultis retryable — same transition, backoff, heartbeat — while a SEMANTIC gate rejection (the error text names a rule: "gate: …", "requires …", "separation of duties", "open child", "malformed") is TERMINAL for that attempt — stop, read what it names, resolve the named precondition or take a lifecycle-legal edge; retrying a gate-rejected transition cannot succeed and every retry is an audited breach event (the 2026-10-28/29 storage-fault era uncorked fourgate:open-childrencards in 18 minutes from exactly these loops — BR#4403, BR#4473). And the BR#4067 release-hold backstop classifies asrelease_held, priced as noise likeclaim_race: a machine sweep's release attempt on a card parked on a typed wait or a merge-train queue slot being refused IS the system working (zero state destroyed — the proxy watch / the answer owns the exit), so no pile-up of refusals ever raises a breach card (BR#4562, specimen TASK-001). The park-drain sweep that kept re-attempting those refusals every tick now checks the same hold its sibling sweeps check before releasing — a held park is waited out, not re-refused — and the full alarm sweep gained anadmin.alarm_sweepdrill, since the drain was previously reachable by no test path at all. - Decisions collect consensus (BR#903): proposing a decision auto-spawns a
Feedback:round (a question task) — agents contribute agree/disagree with reasons vialog_progress; orient surfaces the open rounds (decisions_awaiting_consensus) and For Me shows the tally next to your accept/reject buttons. The round informs you; it never gates you.
npm run demo -w @bridge/webapp # mock hub + web UI, offline
npm run build -w @bridge/webapp # webapp dist is committed and served as worker assets
npm run test -w packages/worker # worker tests (syntax → tsc --noEmit typecheck → bundle dry-run → vitest)
npm run test -w packages/contracts # contract tests: fixtures ↔ zod schemas ↔ the recorded MCP registry ↔ config schema
npm run typecheck -w packages/worker # worker type gate only — TS2304-class undefined names (the Oct 2026 board-500 bug class) fail here at any strictnessEvery surface (web, gateway UI, mobile) renders from one component kit,
packages/ui-kit (@bridge/ui-kit): design tokens (tokens.css) plus
Btn / Chip / Card / Field / TextInput / TextArea / Select /
ActionRow / FormRow / ConnDot / RelTime. Import the css once per
shell (@bridge/ui-kit/ui-kit.css, tokens included) and use the
components — surfaces keep layout css only and may re-point tokens (the
webapp overrides the palette in its :root; mobile sets
--kit-radius: 12px for touch scale). No surface re-declares button,
chip, card or field styles: the drift those per-surface copies caused is
the bug this kit removes.
Docs are part of the change. README.md and packages/site/content.md
describe shipped behaviour — a change that alters user-visible behaviour
(UI, config, protocol, gates) updates both in the same commit. Agents get
the same rule via their protocol card (7b) and the dispatch delivery
contract.
| package | what |
|---|---|
packages/contracts |
@bridge/contracts — the shapes every runtime trusts, verified in CI: zod schemas + production fixtures for orient, the MCP tool table, pairing, provisioning, asks, and the config file |
packages/specs |
the contract: JSON Schemas, wire docs, TS types, consistency gate |
packages/worker |
Cloudflare Worker + KB Durable Object: /mcp, /ingest (bearer), /ws, /api/* incl. machine-plane mirrors (/api/kb-machine/*), serves the webapp |
packages/webapp |
the full web UI (browser-only — no shell forks) |
packages/ui-kit |
@bridge/ui-kit — shared design tokens + Btn/Chip/Card/Field/ConnDot/RelTime; one look for webapp, gateway UI and mobile |
packages/desktop |
gateway desktop app: minimal gateway UI (ui/) + Rust dispatch core (src-tauri/) |
packages/desktop/ui |
the gateway UI (auth, dispatch log stream, agent config, open-in-web) |
packages/mobile/ui |
mobile UI (Fleet + For You + Voice console) — native iOS shell (packages/mobile/shell, Tauri 2) |
packages/cli |
headless bridge runner binary (reuses the desktop dispatch core) |
packages/site |
public landing page (static) |
packages/adapters |
anything → /ingest shippers |
{ "schema_version": 2, // written by the runner; future versions warn "url": "https://bridge.<your-subdomain>.workers.dev", // no "token" — the keychain owns it "workdir": "/path/to/repo", // repo the runner works in "max_sessions": 1, // concurrency: concurrent agent sessions (WIP limit) "backends": { // provider sections — each carries its own config "opencode": { // model optional: unset = opencode's own default config "model": "openrouter/~anthropic/claude-haiku-latest", "agent": "reviewer", // optional agent profile "bin": "/home/me/.local/bin/opencode", // CLI binary override — ABSOLUTE path strongly // recommended under systemd (minimal PATH) "mode": "acp" // transport: "acp" (default, live session updates) | "stdio" }, "kiro": { // profile-driven: the kiro profile pins provider+model } }, "report_minutes": null, // null = agents report like a human (at natural milestones); // number 1-60 = fixed heartbeat cadence, in minutes "poll_secs": 60, // how often the runner polls the board "timeout_mins": 30, // per-session kill switch "isolate": true, // per-task git worktrees + PR branch (default true) "worktree_exempt_repos": [] // repos where per-task worktrees are skipped // (webapp-only checkout class); env override: // BRIDGE_WORKTREE_EXEMPT_REPOS (comma-separated) "identity": { // git commit/signing identity for dispatched // sessions (BR#3185). Omitted = the built-in // worker class (detached-cody per BR#3181). "name": "detached-cody", "email": "297316395+detached-cody@users.noreply.github.com", "signingKeyPath": "~/.ssh/sv-agents-signing.pub", "signingProgram": "/usr/bin/ssh-keygen" } }