Files
beardrive/CLAUDE.md
T
2a7dca252f docs: PR architecture sections carry one diff diagram, not a before/after pair (#107)
Two diagrams make the reviewer do the diffing. The mermaid-diff-diagram skill
folds them into one flowchart with additions marked  and removals  struck
through, so the change reads at a glance. The committed architecture/*.md files
are untouched by this: they stay full-state classDiagram; only the PR excerpt
changes.


Claude-Session: https://claude.ai/code/session_01FNgeJVcsXQ5sTfWLgCR4cv

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 06:26:49 +09:00

29 KiB
Raw Blame History

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 an Op (put/delete) in a per-device append-only JSONL log. Less defines the total order (lamport, time, device, seq); Replay folds 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 — the Backend interface (Put/Get/List/Exists) with file://, s3://, gs://, and https:// implementations (https:// syncs through a bdrive serve server's /api/store API — the client device holds no storage credentials). PutSigner is the optional presign capability (S3/GCS). Remote layout: blobs/<sha256> + journal/<device>.jsonl under 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 in syncer.go first. ignore.go holds the path filter (.bdriveignore rules + the .bdrive include 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.lock in 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.json each 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 on daemon.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 — making status lie and Start a 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; the pid is display-only, and holding the lock also makes two daemons on one mount impossible (two writers of one journal).
  • autostart — the login registration that undoes a reboot: one unit per machine, user-level, running bdrive resume — macOS ~/Library/LaunchAgents/ai.beardrive.daemon.plist (RunAtLoad, deliberately no KeepAlive since the job exits); Linux $XDG_CONFIG_HOME/systemd/user/beardrive.service (Type=oneshot, no Restart=) plus the default.target.wants symlink that systemctl --user enable would create, since systemd ignores a unit nothing wants. Linux also requires systemd to be booted (/run/systemd/system, i.e. sd_booted) — otherwise Install returns ErrUnsupported rather 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 and bdrive stop still means stay stopped. init installs it (--no-autostart skips). Writing the file is the whole job: no launchctl shell-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-user HKCU\...\CurrentVersion\Run value (golang.org/x/sys/windows/registry) — no admin, no COM (a Startup .lnk would need it), no schtasks, 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's Lock uses syscall.Flock and internal/daemon uses syscall.Kill/Setsid, all unix-only — a Windows port means LockFileEx and 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 with ErrUnsupported; shared bits (writeIfDifferent, selfPath) live in the tag-free autostart.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) holds config.json with the mount id + volume/remote/include; nothing is keyed by the folder path, so renames/moves are free — ResolveMount self-heals the registry path, and the volume store lives at ~/.bdrive/volumes/<mount-id>/. .bdrive/ is never synced and holds no credentials.
  • webapp — the bdrive serve server, in two modes. Single-volume: Source is a DirSource (plain folder from disk) or RemoteSource (folds journals into a file tree with per-file provenance). Hub: Root + Projects host many projects on one storage root, each under <root>/<project-id>/ via remote.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-backed orgs.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/projects lists 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 mirroring AuthProvider — CheckWrite/RecordUsage on every write path, CheckSeat on invite redemption; OSS ships only UnlimitedQuota, managed deployments swap the provider. Renders markdown (goldmark + Obsidian [[wikilinks]]). With --upload it accepts writes: browser uploads (upload.go — direct-to-storage via presigned URLs when the backend implements remote.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 — the https:// 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 into src/components/ui, themed from the BearDrive tokens in src/tw.css; TanStack query/table/virtual, react-hook-form + zod, cmdk, sonner, lucide-react) whose built output is committed at webapp/static/ — the go:embed static target — so plain go build needs no Node; after any frontend/src change run npm run build there and commit the new static/ (frontend/check-dist.sh verifies freshness; e2e suite: npm run e2e — Playwright against the seeded harness in e2e_serve_test.go, port 8993). It learns everything from /api/config (+ /api/projects in 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_ROUTES in router.ts; renamed segments live in LEGACY_VIEWS and 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 router frontend/src/nav.ts + router.ts (deliberately NOT a router library: react-router v7's startTransition navigation left stale views on screen); Server.frontend serves index.html as the SPA fallback for any non-asset, non-API/auth/share route so deep links and refreshes resolve (hashed assets/* 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. Rendered markdown is transformed as a string before mounting and link clicks are delegated on the container — never patch the dangerouslySetInnerHTML subtree after commit (React re-applies the markup on unrelated updates and discards DOM patches). Read heat (reads.go): a ReadLedger (hub-only, nil = off, config reads block) aggregates read telemetry into daily per-actor buckets, debounced to 10-minute visits, folded into all-time rows past retention_days — viewer file/render/download = human (recorded via the project id the proj() resolver stashes in the request context), /s/* hits = share, device-reported reads (POST /api/p/<id>/reads) = agent; /store/* replication and history /blob views 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. 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 /heat is membership-gated and identity-free. Hub metadata persistence (accounts, projects, orgs+invites, shares, devices, read buckets — never blobs or journals) sits behind a pluggable MetaStore of 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 (the ReadRepo alone is batch-oriented — one flush, one write). Two backends — db_file.go (the historical JSON files, still the zero-dep default, reached via the Open*(path) constructors) and db_sql.go (one database/sql impl over pure-Go drivers: modernc.org/sqlite locally, jackc/pgx for Postgres/Supabase, portable schema + idempotent migrations + transactional multi-row writes). web.go's database config ({driver:file|sqlite|postgres, dsn}) selects it; file is default and untouched. db_conformance_test.go runs 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, versionmnt/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 clears the saved token+account (keeps the remembered server unless --forget). Switching hubs is bdrive login <new-url> then re-initinit 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.Less or Replay changes 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 of stats and at most one grep of mounts.json.
  • A daemon's liveness is its daemon.lock flock, never its pidfile. Anything asking "is the daemon running" goes through daemon.Running; a pid is for display and for signalling only.
  • Cycle runs under the volume flock — the daemon and one-shot CLI commands (bdrive sync) coexist through it.
  • Errors during pull/push degrade to Result.Offline rather 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/syncerif 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. 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.md documents 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's ConnectGuide.tsx all 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 the self-hosting/ pages are the ones that go stale.