A teammate's agent rewrites a file and yours never hears about it — the only trace is a `.bdrive-conflict-*` nobody opens. Now the turn-start hook names the paths that arrived since the last turn: "re-read before editing". The record lives in a spool (`internal/store/inbound.go`), not in Cycle's Result, because the daemon usually materializes a peer's change seconds before the turn starts — so the hook's own cycle sees nothing. materialize appends every path it writes or removes, `bdrive sync --hook` drains it after its cycle and renders it under each mount's own prefix (stripping the session subpath when the run is inside a mount, dropping paths outside it). Advisory only: nothing blocks, nothing prompts, no per-Write remote call. The spool is capped, 0600, in the volume dir, and best-effort everywhere — a spool failure never fails a cycle or a turn. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
31 KiB
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
What this is
BearDrive is the product name; bdrive is its CLI binary (file conventions: the .bdrive/ settings directory and .bdriveignore at the project root, ~/.bdrive home, BDRIVE_HOME). BearDrive is a Go CLI that mounts any folder as a synced volume: contents sync across devices and teammates through a bdrive serve hub, with accounts, per-file change history, and offline support. Clients are storage-blind — they sync through the hub over https:// and never hold storage credentials; the hub owns the object store (S3, GCS, S3-compatible, or a plain directory) and devices converge through append-only journals in it. (Direct client-to-bucket sync without a hub is no longer supported; the object-storage backends exist only as the hub's own storage.)
The repo ships one binary: cmd/bdrive — the CLI, the sync daemon, and the web server (bdrive serve: viewer, uploads, multi-project sync hub).
Commands
go build ./... # build everything
go test ./... # run all tests
go test ./internal/syncer -run TestConflict -v # run a single test
go vet ./... # vet
go build -o bdrive ./cmd/bdrive # build the binary (gitignored at repo root)
# web frontend (internal/webapp/frontend — only needed when changing frontend/src):
npm run build # rebuild the committed assets in internal/webapp/static (run from frontend/)
npm run e2e # Playwright suite against the seeded e2e hub (starts itself on :8993)
./check-dist.sh # verify committed static/ matches frontend/src (run before releases)
There is no Makefile, linter config, or CI config in-repo. Releases run goreleaser release on a tagged commit (see .goreleaser.yaml); the version is injected via -ldflags "-X main.version=..." into cmd/bdrive/main.go. The frontend's built assets are committed (go:embed needs them in the module), so go build and go install never require Node — but a release tag must not ship a stale internal/webapp/static: run frontend/check-dist.sh first.
When testing the CLI manually, set BDRIVE_HOME=/some/tmp/dir to relocate all beardrive state (device identity, mount registry, volume stores) away from the real ~/.bdrive.
Architecture
Data flows in two hops; the local volume store is the pivot:
working folder ←scan/materialize→ volume store (~/.bdrive/volumes/<vol>) ←push/pull→ object store
(real files) blobs/ + journal/ + state + sync s3:// gs:// file://
Package roles (internal/):
journal— the core data model. Every change is anOp(put/delete) in a per-device append-only JSONL log.Lessdefines the total order(lamport, time, device, seq);Replayfolds all ops into the volume state, last-writer-wins per path. Everything else is machinery around this.store— a volume's local on-disk state: content-addressed blob store (blobs/<aa>/<sha256>), per-device journal copies, the per-mount materialization cache (state-<mount-id>.json, size+mtime fingerprints for cheap change detection), sync state (lamport clock + push cursor), and the exclusive flock that serializes cycles.remote— theBackendinterface (Put/Get/List/Exists) withfile://,s3://,gs://, andhttps://implementations (https://syncs through abdrive serveserver's/api/storeAPI — the client device holds no storage credentials).PutSigneris the optional presign capability (S3/GCS). Remote layout:blobs/<sha256>+journal/<device>.jsonlunder the URL prefix.syncer— the heart:Session.Cycle()runs one pass: scan → commit local ops → pull peer journals → preserve conflict copies → materialize merged state → push blobs + own journal. Read the package doc comment insyncer.gofirst.ignore.goholds the path filter (.bdriveignorerules + the.bdriveinclude list), applied symmetrically in scan and materialize; a newly filtered path is dropped from the cache without a delete op so opting out locally never deletes remotely.daemon— per-mount background loop (detached process,daemon.pid/daemon.log/daemon.lockin the mount's volume dir). Scans every--scan-interval(3s), talks to the remote every--remote-interval(10s) or immediately after local edits. Re-reads.bdrive/config.jsoneach tick; if it vanishes (folder moved/renamed/deleted) the daemon exits cleanly without propagating deletes — the next bdrive command at the new location resumes it (self-heal on next touch). Liveness is the flock ondaemon.lock, never the pidfile: the pidfile outlives its process (it lives in$BDRIVE_HOME, which survives reboots), so a recycled pid used to read as a live daemon — makingstatuslie andStarta silent no-op, which broke the one documented recovery. The kernel drops the flock when the holder dies, including at reboot and on a crash, and holding the lock also makes two daemons on one mount impossible (two writers of one journal). The pidstopsignals is written inside the lock file and cleared with it —daemon.pidis display-only, since nothing binds its contents to the lock holder and akill -9'd daemon leaves it behind for a recycled pid to inherit. A mid-run change to the folder'sremoteis not followed: the daemon exits cleanly and the next bdrive command in the folder starts one for whatever the config then says.autostart— the login registration that undoes a reboot: one unit per machine, user-level, runningbdrive resume— macOS~/Library/LaunchAgents/ai.beardrive.daemon.plist(RunAtLoad, deliberately noKeepAlivesince the job exits); Linux$XDG_CONFIG_HOME/systemd/user/beardrive.service(Type=oneshot, noRestart=) plus thedefault.target.wantssymlink thatsystemctl --user enablewould create, since systemd ignores a unit nothing wants. Linux also requires systemd to be booted (/run/systemd/system, i.e.sd_booted) — otherwiseInstallreturnsErrUnsupportedrather than writing a file nothing would ever read (Alpine/runit, WSL1, slim containers), which starts a daemon for every enrolled, unpaused mount — so mounts added later need no re-registration andbdrive stopstill means stay stopped.initinstalls it (--no-autostartskips). Writing the file is the whole job: nolaunchctlshell-out, so a test or a packaging script can't register a real login item as a side effect, and launchd loads it at the next login anyway. Windows uses a per-userHKCU\...\CurrentVersion\Runvalue (golang.org/x/sys/windows/registry) — no admin, no COM (a Startup.lnkwould need it), noschtasks, and it shows up in Task Manager's Startup tab where a user can disable it. Its tests exist but have never been executed (written and compile-checked from macOS); they run the first time the suite runs on Windows.GOOS=windows go build ./...does not pass yet and this package is not the reason:internal/store'sLockusessyscall.Flockandinternal/daemonusessyscall.Kill/Setsid, all unix-only — a Windows port meansLockFileExand a kill story for a platform with no SIGTERM, which is a separate change against the sync invariants.autostart_other.go(!darwin && !linux && !windows) covers the BSDs withErrUnsupported; shared bits (writeIfDifferent,selfPath) live in the tag-freeautostart.go.config— global state under$BDRIVE_HOME(default~/.bdrive): device identity (device.json), settings (settings.json: default server + device token + signed-in account), and the mount registry (mounts.json, keyed by stable mount id, holding only each mount's last-known path). The per-folder.bdrive/directory (project.go) holdsconfig.jsonwith the mount id + volume/remote/include; nothing is keyed by the folder path, so renames/moves are free —ResolveMountself-heals the registry path, and the volume store lives at~/.bdrive/volumes/<mount-id>/..bdrive/is never synced and holds no credentials.webapp— thebdrive serveserver, in two modes. Single-volume:Sourceis aDirSource(plain folder from disk) orRemoteSource(folds journals into a file tree with per-file provenance). Hub:Root+Projectshost many projects on one storage root, each under<root>/<project-id>/viaremote.Prefixed;ProjectDB(projects.go) is a file-backed registry (JSON, loaded at open, rewritten atomically per change) with create-or-join-by-name semantics, name-scoped per organization. Orgs (orgs.go, file-backedorgs.json) wall projects by membership (email → owner|member): every per-project route — viewer APIs, uploads, history, shares management, the/store/*sync proxy — 403s for non-members,/api/projectslists only your orgs' projects, owners mint expiring multi-use invite links (/join/<token>), and a pre-org hub migrates all projects into a "default" org (all existing accounts join, oldest owns) at startup.QuotaProvider(quota.go) is the plan-enforcement seam mirroringAuthProvider— CheckWrite/RecordUsage on every write path, CheckSeat on invite redemption; OSS ships onlyUnlimitedQuota, managed deployments swap the provider. Renders markdown (goldmark + Obsidian[[wikilinks]]). With--uploadit accepts writes: browser uploads (upload.go— direct-to-storage via presigned URLs when the backend implementsremote.PutSigner, relayed otherwise; ops journaled under the server's own device) and the per-project/api/p/<id>/store/*proxy (store.go) that whole devices sync through — thehttps://remote backend (remote/http.go) is its client; journals are never presigned, only immutable blobs. Frontend is a React + TypeScript app (webapp/frontend/, Vite + Tailwind v4 + shadcn/ui — Radix-based components copied intosrc/components/ui, themed from the BearDrive tokens insrc/tw.css; TanStack query/table/virtual, react-hook-form + zod, cmdk, sonner, lucide-react) whose built output is committed atwebapp/static/— thego:embed statictarget — so plaingo buildneeds no Node; after anyfrontend/srcchange runnpm run buildthere and commit the newstatic/(frontend/check-dist.shverifies freshness; e2e suite:npm run e2e— Playwright against the seeded harness ine2e_serve_test.go, port 8993). It learns everything from/api/config(+/api/projectsin hub mode) and never sees storage info or credentials. It uses native History-API path routing (/<project-id>/<path>in hub mode,/<path>in volume mode,/join/<token>for invites — no#, slashes stay literal). Every user-facing page owns a URL path: new surfaces are view routes (/<project-id>/{dashboard|history|install|settings}[/<path>],VIEW_ROUTESinrouter.ts; renamed segments live inLEGACY_VIEWSand are normalized away on arrival) so deep links, reload, and back/forward always work — never URL-less panel state (the org/hub admin panels are the legacy exceptions; don't add more) implemented by the in-repo synchronous routerfrontend/src/nav.ts+router.ts(deliberately NOT a router library: react-router v7's startTransition navigation left stale views on screen);Server.frontendservesindex.htmlas the SPA fallback for any non-asset, non-API/auth/share route so deep links and refreshes resolve (hashedassets/*are cached immutable, everything else no-cache), and all client API/asset URLs are root-absolute so a deep path doesn't break relative resolution; the shell also carriesnosniff+frame-ancestors 'none', andimmutableis set only after an asset is found (a miss underassets/must not pin the shell in a shared cache for a year). Rendered markdown is transformed as a string before mounting and link clicks are delegated on the container — never patch thedangerouslySetInnerHTMLsubtree after commit (React re-applies the markup on unrelated updates and discards DOM patches). Read heat (reads.go): aReadLedger(hub-only, nil = off, configreadsblock) aggregates read telemetry into daily per-actor buckets, debounced to 10-minute visits, folded into all-time rows pastretention_days— viewer file/render/download = human (recorded via the project id theproj()resolver stashes in the request context),/s/*hits = share, device-reported reads (POST /api/p/<id>/reads) = agent;/store/*replication and history/blobviews are NEVER reads.GET /api/p/<id>/heat?prefix=&days=returns counts/distinct-readers/last-read only — actor identities (the email/device/token in the buckets) must never appear in an API response, with one stated exception:?by=devicereports agent device ids, which History already shows every project member. That exception holds only because ingest validates the id —POST /api/p/<id>/readsrecords a read as an agent actor only when the reportedX-Bdrive-Deviceis shaped like a device id at all and no OTHER account has been seen syncing under it (devices.go:ownsDevice→DeviceRegistry.MayActAs); otherwise the report is accepted and counted for nobody. That route never registers a device: only/store/*traffic does, and it registers per(account, id), so naming someone else's id claims nothing and cannot lock its real owner out. Emails and share tokens never leave the server in any shape. Recording and flushing degrade silently (log once); telemetry must never fail a request or a sync cycle. The frontend shows heat dots on folder listings and a per-project Dashboard quadrant (reads × staleness, route/<project-id>/dashboard) — both visible to every project member, since/heatis membership-gated and identity-free. Hub metadata persistence (accounts, projects, orgs+invites, shares, devices, read buckets — never blobs or journals) sits behind a pluggableMetaStoreof typed repos (db.go): the service structs (BuiltinAuth,OrgDB,ProjectDB,ShareDB,DeviceRegistry,ReadLedger) keep their in-memory maps + logic and persist each change as one record through a repo (theReadRepoalone is batch-oriented — one flush, one write). Two backends —db_file.go(the historical JSON files, still the zero-dep default, reached via theOpen*(path)constructors) anddb_sql.go(onedatabase/sqlimpl over pure-Go drivers:modernc.org/sqlitelocally,jackc/pgxfor Postgres/Supabase, portable schema + idempotent migrations + transactional multi-row writes).web.go'sdatabaseconfig ({driver:file|sqlite|postgres, dsn}) selects it; file is default and untouched.db_conformance_test.goruns the same service ops against every backend.
cmd/bdrive/ is a thin cobra CLI over these packages (login, logout, init, stop, sync, scope, forget, status, log, share, export, import, url, hooks, resume, autostart, read-log, serve, whoami, daemon, version — mnt/umnt/remote are gone; init is the front door and stop pauses). export/import (migrate.go) move a whole project between hubs with full fidelity: the archive is the remote store layout (all devices' journals + all blobs) in a tar.gz, streamed through the existing remote.Backend — no server-side support needed, so it works against any hub in either direction (the anti-lock-in story for cloud-hesitant users). bdrive login signs the device in (bare form uses the remembered server or config.DefaultServer = beardrive.ai; loopback-callback browser flow in login.go, --device for headless) and stores server+token+account in settings.json; bdrive logout revokes this device's token on the hub (DELETE /api/auth/token, authenticated by the token itself) and then clears the saved token+account, keeping the remembered server unless --forget; a revocation it could not reach the hub for is reported, never swallowed. Switching hubs is bdrive login <new-url> then re-init — init is the only thing that writes a folder's remote (always a hub, server + "/p/" + id); there is no client command to point a folder at a raw bucket. bdrive init is interactive on a TTY (survey menus: create-new vs connect-existing with a project list; whole-folder vs only-some-subfolders) with full flag bypass (--name/--project/--only/--yes) and never prompts without a TTY; it runs the login flow first when there is no session, writes .bdrive/config.json, seeds .bdriveignore, registers agent sync hooks in each platform's USER config (~/.claude/settings.json and friends — once per machine, never inside a project: platforms read hook config only from the directory a session starts in, so a per-project file covers only sessions that start there and, living in a mount, would sync to the team; Install also migrates away hooks older versions wrote into projects), and starts sync via startSync; re-running it resumes — including after a folder move. A mount is always exactly the folder named — there is no re-rooting flag. Syncing only part of a mount is --only wiki,docs, which writes a bdrive-managed block of .bdriveignore negation rules (cmd/bdrive/scopefile.go; bdrive scope add/rm edits the same block) rather than a second scope mechanism: the old Include list in config.json is legacy — still honored, never written. Because the rules live in the synced .bdriveignore, scope is team-wide, which is why sync --prune refuses when ! rules are present (it would strip everything outside the scope from the hub for everyone; bdrive forget <path> is the per-path tool). init also refuses a second folder for a project this device already syncs — one device writes one journal per project, so two mounts would overwrite each other's ops. bdrive serve -c config.json configures the server from a file, explicit flags winning.
Authentication (webapp/auth.go, authlocal.go, mail.go) is mandatory in hub mode — the config's auth block tunes users_db/allow_signup/allowed_domains/require_verification/require_approval/admins/smtp; the plain-folder viewer stays auth-free — and sits behind the AuthProvider interface — the OSS server ships only BuiltinAuth (email+password accounts and device tokens in a file-backed auth.json; bcrypt for passwords, SHA-256 digests for tokens, plaintext never stored; server-owned /auth/* pages; one-time codes for the CLI callback and device flows; SMTP reset mail with a log-link fallback). Signup is invite-only by default (allow_signup defaults false): a valid org invite bootstraps an account even when self-signup is closed — BuiltinAuth.InviteValid (wired to OrgDB.ValidInvite) lets pageSignup/pageLogin offer account creation for a /join/<token> target, and signupInvited skips the domain/verification/approval gates and activates immediately (the invite is the vetting). BuiltinAuth.ValidateSignupPolicy (called at hub startup, web.go) refuses an ungated open hub and email-verification-without-SMTP rather than silently leaving the door open. The three postures: invite-only (default), approval-gated (require_approval), and domain-restricted+verified (allowed_domains+require_verification+smtp); allow_signup/allowed_domains/admins stay server-config-owned so a browser session can't widen access. A managed deployment can swap in a different provider (e.g. PropelAuth) without touching the CLI or API — keep provider-specific code out of this repo. The sync client picks up its token from BDRIVE_TOKEN or settings.json and sends X-Bdrive-Device{,-Name,-Os} headers (remote/http.go); the hub's file-backed device registry (webapp/devices.go) records per-device name/OS/account/server-observed IP. Journal ops carry the signed-in account (Op.User/UserName from Session.Account; Author remains the git/OS fallback). History (webapp/history.go): GET /api/p/<id>/history?path=|prefix= (newest first, device-registry join) and GET /api/p/<id>/blob?sha= stream any exact version — blobs are retained forever, so the future revert phase is just re-putting an old blob as a new op. Share links (webapp/shares.go, file-backed shares.json): any signed-in member mints /s/<token> public URLs (bdrive share, or the UI's Share button) serving the file's LATEST content until revoked (optional expiry); /s/* responses are sandboxed (CSP sandbox allow-scripts, no auth cookies) so shared HTML can't attack hub sessions — keep that header on any change; /s/* also sits behind a per-IP token bucket (ratelimit.go, share_rpm config), and markdown share pages get a "Shared with BearDrive" footer (raw HTML is never injected into).
Invariants — do not break these
- Each device writes only its own journal. This is the whole concurrency story: no locking service is needed because no object ever has two writers. Never write to another device's journal file or remote key.
- Blobs are pushed before the journal (
syncer.push), so a peer never sees an op whose content is missing. Preserve this ordering. - Scan happens before pull in
Cycle, so local edits are journaled (and content captured) before remote state can overwrite the working folder. - Replay must stay deterministic. Any change to
journal.LessorReplaychanges what every device converges to. - Materialize never clobbers dirty files: a file whose size/mtime differs from the state cache changed mid-cycle and is left for the next scan.
- All state files are written atomically (temp file + rename, see
store.WriteFileAtomic). Temp files are prefixed.bdrive-tmp-and ignored by the scanner. - The agent hook guard stays pure shell. It runs on every session and every tool call on the machine, so it must never spawn
bdrive(or anything else) outside a BearDrive project — a couple ofstats and at most onegrepofmounts.json. - A daemon's liveness is its
daemon.lockflock, never its pidfile. Anything asking "is the daemon running" goes throughdaemon.Running; a pid is for display and for signalling only. Cycleruns under the volume flock — the daemon and one-shot CLI commands (bdrive sync) coexist through it.- Errors during pull/push degrade to
Result.Offlinerather than failing the cycle; unreadable/vanished files during scan are skipped and retried next cycle. Follow this "never break sync, retry next cycle" posture.
Testing conventions
The real coverage is the integration tests in internal/syncer/syncer_test.go: each test builds multiple simulated devices (newDevice) syncing through a shared file:// remote (sharedRemote), then drives explicit cycle() calls to test convergence, offline operation, and concurrent-edit conflicts. Extend these when touching sync behavior — a new sync feature without a multi-device test is untested where it matters.
sandbox/ is a disposable Linux container to run a scenario in when it needs one — an environment, not a test suite (./sandbox/run.sh, which cross-builds the binary and takes BDRIVE_SRC=<other checkout> to test a branch without disturbing your tree). It provides a hub, a seeded account, browserless sign-in (bdrive-signin), Claude Code, and a $HOME that is thrown away, so bdrive init writes its device identity and its agent hooks somewhere other than yours. Reach for it only when a Go test cannot do the job: a real claude session with the real permission classifier, Linux-only paths like the systemd user unit, or a reboot simulated by killing processes while the filesystem survives. Everything deterministic and machine-local belongs in internal/webapp/cli_e2e_test.go (which already isolates HOME and drives the real binary) or internal/syncer — if it doesn't need a conversation or an OS, it's a Go test. The two scripts it ships (onboarding.sh, daemon-linux.sh) are the scenarios that cannot live anywhere else; don't grow a suite in here.
Agent integration
There is no Claude Code plugin and no bundled skill: the integration is
internal/agenthooks alone, and INSTALL_FOR_AGENTS.md (repo root) is the
onboarding runbook every agent follows. bdrive init registers the hooks in
each platform's USER config (~/.claude/settings.json and friends), once per
machine: a blocking pull at UserPromptSubmit — which, via bdrive sync --hook, also injects the project's gated-link formula as additionalContext
so agents append path [🔗](hub link) to every synced path they mention. One
run can cover several mounts (syncTargets) and the hook's stdout contract is
a single JSON object, so the formula carries every mount as a prefix → URL pair — the prefix being the mount's path as the agent sees it from the
session's folder, or an empty prefix with the session's own subpath baked into
the URL when the session runs inside the mount; emitting only the first mount
hung one project's paths on another project's base URL. The same context also
names what teammates changed since the last turn ("re-read before editing"),
drained from the inbound spool (internal/store/inbound.go, a near-copy of
the read spool): materialize appends every path it writes or removes, and the
hook drains it after its own cycle — a Result field would report nothing,
because the daemon has usually materialized the peer's change seconds earlier.
Advisory only: nothing blocks a write. Then an
async push on PostToolUse Write/Edit, and bdrive read-log on
Read/Grep/Bash for the read heatmap. The inline hook commands
internal/agenthooks writes must stay a fast no-op outside BearDrive folders
— they run on every turn of every session on the machine, so the guard is
pure shell (a couple of stats, at most one grep of mounts.json) and
never spawns the binary outside a mount.
Docs site (web/docs)
web/docs/ is the public documentation at docs.beardrive.ai — Astro 7 + Starlight (Starlight requires Astro ^7; the cloud landing is still on Astro 5 — separate projects, so they upgrade independently), static output, Pagefind search, llms.txt via starlight-llms-txt. Unlike the hub frontend (internal/webapp/static) and the cloud landing page (cloud/internal/landing/dist) it is not go:embeded: docs change far more often than the binary, and a search index has no business shipping in every self-hoster's install. It deploys on its own from dist/, so go build never touches it. Sidebar order is explicit in astro.config.mjs — a new page under src/content/docs/ is invisible until listed there, and every page needs a description (meta description, search snippet, and llms.txt line). The sidebar order is the recommended path, and that path is agent-first: Start here (what it is → set up with your agent → your first hour) never mentions installing a binary; the CLI route lives in Manual setup (optional) (install the CLI, set up by hand, hooks in detail) — same destination, one click away, never on the critical path. New onboarding content belongs in Start here and should say what to ask an agent, not what to type. Use cases (after Manual setup) holds job-shaped pages — "Share work across your team's agents", "Turn a personal brain into a company brain" — with the persona named in the first line and the description, not in the title; they route into the guides and never re-teach a feature. Moved URLs keep redirects entries in astro.config.mjs (static builds emit meta-refresh; real 301s live in the host config, see web/docs/README.md). Guides are agent-workflow docs, not CLI tutorials (Working with agents: shared agent memory, artifacts/links, read heat, scoping); command-by-command detail belongs in reference/cli.md. Design tokens are generated, never copied: scripts/tokens.mjs reads the @theme block in internal/webapp/frontend/src/tw.css and emits the gitignored src/styles/tokens.gen.css, which src/styles/custom.css maps onto Starlight's --sl-color-* — so the palette cannot drift and there is no checker to maintain (contrast the cloud landing, which keeps a copy policed by its own check-tokens.mjs). Because the build reads that file outside web/docs, a deploy host must check out the whole repo, not the subdirectory. Note llms.txt convention wants the root domain, so beardrive.ai/llms.txt should point at the docs subdomain — that redirect lives in the cloud landing and is the one cross-repo coordination point.
PR descriptions
Every PR body starts with a ## TL;DR section: at most 5 bullet points, one short informal sentence each — fewer is better. Lead with the user-visible symptom/outcome, not the mechanism; include a known-gap bullet if the PR knowingly leaves one. Detail sections follow after.
Architecture diagrams in PRs
architecture/ holds mermaid diagrams covering every application package in the repo: overview.md (system diagram — the package map and how the pieces connect), cli-sync.md (the CLI and sync engine — cmd/bdrive + internal/{syncer,store,journal,config,daemon,agenthooks,autostart}), webapp-server.md (the bdrive serve server — internal/webapp + internal/remote), and webapp-frontend.md (the hub's React SPA — internal/webapp/frontend/src). Every code change lands in exactly one detail diagram's scope; overview.md changes only when packages appear/disappear or the cross-piece wiring changes (web/docs and the private cloud/ repo are deliberately out of scope). Before gh pr create: if the branch changes types or relationships drawn in any of them (new/removed types, new seams, changed fields/implements/ownership in those packages), update the affected diagram and commit it on the branch, then add an "Architecture changes" section to the PR description with, per changed diagram: (1) a sentence naming exactly which types/relationships changed and how, (2) one consolidated diff diagram per changed file — invoke the mermaid-diff-diagram skill, which marks additions (✅) and removals (❌, struck through) in place on a single flowchart, instead of pasting a Before block and an After block. Feed it the merge-base diagram as before and the branch's as after, and keep it an excerpt containing only the affected classes and their immediate relationships — never the full diagram. The committed diagram file stays the full current state and stays classDiagram; the diff excerpt lives only in the PR description. No structural change → no section, and append # skip-diagram-check to the gh pr create command to satisfy the pre-PR hook.
Docs to keep in sync
README.mddocuments CLI behavior, flags, output formats, and the on-disk layout. When changing CLI commands, flags, output, or layout, update it.INSTALL_FOR_AGENTS.md(repo root) is the URL-addressable agent onboarding runbook and the source of truth for that flow — the canonical two-line paste prompt (README, docs, and the hub'sConnectGuide.tsxall point at its raw URL) tells any agent to fetch and follow it. When the CLI's init/login/hooks steps change, update it too.web/docs/src/content/docs/is the third surface: it restates the same CLI reference, hub config, and self-hosting knobs for end users. A CLI or config change lands there too —reference/cli.md,reference/hub-config.md,reference/project-files.md, and theself-hosting/pages are the ones that go stale.