Written from the conversation of 2026-08-12. Nothing implemented; every claim about the current tree was checked on that date. The short version: secrets stay in Postgres, the keys move out of .env into a small SQLite store, and rotation becomes an operation instead of data loss. What is actually wrong today is narrower than "secrets in .env" and worth stating precisely, because the separation that exists is correct and must survive a refactor: secrets live in Postgres and the key that opens them does not. The problem is blast radius across processes — Bun auto-loads .env, so VAULT_STORE_KEY sits in the environment of all twenty pm2 processes, and officer-music holds the key that decrypts wallet seed envelopes for no reason. The document records the decisions and, more usefully, what was ruled out: Keys cannot go in Postgres. A dump would carry the ciphertext and the thing that opens it. Encrypting the key with a second key only moves the question — one secret has to be readable without any other, and the only decision is where it lives. The store is not encrypted at rest, and this was tested rather than assumed: stock SQLite silently ignores unknown pragmas, so `PRAGMA key` succeeds, encrypts nothing, and the value is readable with `strings`. bun:sqlite ships stock SQLite 3.53.0. Whole-file encryption needs SQLCipher, which is a second native dependency, and this project already knows what one of those costs. SQLite rather than a flat file for rotation, not secrecy: rotation needs key VERSIONS, since an interrupted rotation needs the old key and the new one to both exist. The file must not live in $OFFICER_ROOT/data/ — that is what people back up, and a key store in the same tarball as a database dump rebuilds the problem. It also records the core/plugin split the design assumes: light plus officer-headscale is the core, because CLAUDE.md rests the security model on the tailnet and a model that rests on the tailnet cannot treat administering it as optional. Vaultwarden and the wallet become plugins. Moving headscale into light removes it from the app store automatically, since catalogue.test.ts asserts the catalogue equals full minus light. Five open questions are left open rather than guessed at. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
403 lines
25 KiB
Markdown
403 lines
25 KiB
Markdown
# CLAUDE.md
|
|
|
|
## Project Overview
|
|
|
|
Officer is a self-hosted platform built around one person — the server owner — which since 2026-08-07
|
|
also admits **additional accounts holding a strict subset of it**. It bundles an AI agent, a terminal,
|
|
a file browser, a code editor, email, a bitcoin wallet, a remote desktop and customisable dashboards
|
|
behind a single web app.
|
|
|
|
**The owner/member split, and where the line falls.** This file said "single-user is a hard invariant,
|
|
not a stage" until 2026-08-07. That is no longer true and had already stopped being true when it was
|
|
written: `users` holds six rows. The accurate statement is narrower and more useful —
|
|
|
|
- **One owner.** User id 1, role `Super Admin`, created by `POST /auth/bootstrap` while the table is
|
|
empty, pinned there by a CHECK constraint. The owner bypasses every permission check.
|
|
- **Other accounts get only what their ROLE is granted.** Roles are `Admin`, `Member`, `Developer`;
|
|
grants live in `role_capabilities`, keyed on role, never on user. Absence denies — there is no row
|
|
meaning "no", so an empty table is a server where members reach nothing but their own profile.
|
|
- **Some things can never be shared, structurally.** Terminal, chat, tasks, files, desktop and browser
|
|
are `kind: 'execution'` in the capability registry: they run as the owner's OS user in the owner's
|
|
home, so there is no level of "read" that makes them safe. They have no level at all and the grants
|
|
API refuses to store one.
|
|
|
|
So "which user is this" now has a real answer for the **app** surface (gitea, music, photos, email,
|
|
calendar…), and is still always "the owner" for anything that executes code or touches the disk.
|
|
|
|
`src/servers/capabilities/registry.ts` is the authority and reads as the design document for this.
|
|
**Mounting a router without a registry entry makes the server refuse to boot** — see "Capabilities"
|
|
below before adding one.
|
|
|
|
**Still single-user: account creation.** `createUser` has exactly one call site, `auth/bootstrap.ts`,
|
|
gated on an empty table. There is no signup route, no invite flow and no admin create-user handler, so
|
|
every existing member was inserted into Postgres by hand. That is the largest gap in the model, not a
|
|
deliberate boundary.
|
|
|
|
## Architecture
|
|
|
|
One Bun process (`src/server.tsx`) serves everything:
|
|
|
|
- the React SPA, via Bun's HTML import of `src/apps/officer-web/index.html` (HMR in dev)
|
|
- the REST API, a Hono app mounted at `/api` (`src/servers/hono.ts`)
|
|
- eight WebSocket providers — terminal, chat, task-runner, pipeline, cliamp, cliamp-audio, desktop,
|
|
vault — plus a sidecar registration socket. `terminal` is a byte relay onto the pty sidecar's own
|
|
listener, not a translating bridge; `vault` is the same shape onto Vaultwarden's notifications hub.
|
|
- a browser relay on its own port (`BROWSER_RELAY_PORT`, default 18792)
|
|
|
|
Long-running and privileged work lives in **sidecars**: separate processes that dial back in over
|
|
`/api/sidecar/register` and are tracked in `src/servers/sidecar-registry.ts`. PM2 runs them
|
|
(`ecosystem.config.cjs`): `officer` (the server), `officer-anthropic-proxy`, `officer-agent`,
|
|
`officer-opencode`, `officer-email`, `officer-pty`, `officer-vnc`, `officer-music`, `officer-vault`,
|
|
`officer-slskd`, `officer-headscale`, `officer-transmission`, `officer-invoiceshelf`, `officer-wallet`,
|
|
`officer-photos`, `officer-notify`, `officer-caldav`, `officer-memos`, `officer-jellyfin`, `officer-gitea`
|
|
— twenty as of 2026-08-06, and a list that goes stale every time a sidecar lands. `pm2 jlist` is the
|
|
source of truth.
|
|
|
|
**`officer-anthropic-proxy` and `officer-agent` are not the same thing.** The proxy holds the Anthropic
|
|
credential and forwards API traffic; the agent is the process that spawns `claude`. They were one entry
|
|
named `officer-claude` until the sidecar-isolation work — which is exactly how the false claim that
|
|
"restarting officer doesn't disturb the agent" survived so long. Every sidecar is a PM2 peer of
|
|
`officer`, so **no sidecar is a child of the server and restarting the server does not kill one.**
|
|
|
|
Agents run **unsandboxed as the server owner**, with `--dangerously-skip-permissions`. This is
|
|
deliberate — it is the owner's own machine. Do not add a jail without being asked.
|
|
|
|
## Repository Layout
|
|
|
|
```
|
|
src/
|
|
├── server.tsx # the single entrypoint: routes, WS upgrades, static files
|
|
├── apps/
|
|
│ ├── officer-web/ # the SPA shell — Screens/Authentication + Screens/Dashboard
|
|
│ └── landing/ # marketing landing page
|
|
├── servers/
|
|
│ ├── hono.ts # router composition; everything under /api
|
|
│ ├── _middlewares/ # auth, body parsing, origin validation, rate limiting
|
|
│ ├── api/<feature>/ # one folder per feature, each exporting a router
|
|
│ ├── channels/ # send-claude-code / send-opencode — how /chat drives an agent turn
|
|
│ ├── queue/ # background job engine
|
|
│ └── sidecar/ # sidecar implementations + the wire protocol
|
|
├── databases/officer_db/ # the only database (Postgres + Drizzle)
|
|
├── extensions/browser-relay/
|
|
└── workspaces/ # shared packages, each a bun workspace
|
|
```
|
|
|
|
`src/workspaces/officerdev` is the biggest of these: the windowed "apps" (FileBrowser, Chat,
|
|
Terminal, CodeEditor, Desktop, Dashboards, Wallet…) that the shell hosts, behind an `AppRegistry`.
|
|
`src/apps/officer-web` is only the shell — screens, routing and settings.
|
|
|
|
**Path aliases** (`tsconfig.json`): `@/*` → `src/apps/officer-web`, `@/components/*` →
|
|
`src/workspaces/components`, `@@/*` → `src/servers`, `@/public/*` → `public`. Workspace packages are
|
|
imported by their package name (`officerdev`, `hooks`, `state`, `types`, `helpers`, …).
|
|
|
|
## Tech Stack
|
|
|
|
- **Runtime**: Bun (Node 22 or newer is enforced by a `preinstall` check)
|
|
|
|
That check demanded *exactly* 22 until 2026-08-12. The reason was a `node-pty` build
|
|
failure some months earlier, whose details were not recorded. It was relaxed to `>= 22`
|
|
after confirming node-pty ships **no Linux prebuilds** — its install script always falls
|
|
through to `node-gyp rebuild`, so it compiles against whatever Node is present and there
|
|
is no ABI to mismatch. Untested on 24 at the time of the change. If `bun install` fails
|
|
building node-pty, or `officer-pty` cannot load its native module, restore the exact pin
|
|
first. The source build also needs `build-essential` and `python3`.
|
|
- **Language**: TypeScript, strict. `bunx tsgo` is clean — keep it that way.
|
|
- **Frontend**: React 19, React Router 7, React Query, Tailwind 4, shadcn/ui + custom components
|
|
- **Backend**: Hono
|
|
- **Database**: one Postgres database via Drizzle
|
|
- **Agents**: Claude Code and opencode, driven through sidecars; tools exposed over MCP
|
|
|
|
## Data
|
|
|
|
Two stores, and the split matters:
|
|
|
|
**Postgres** (`src/databases/officer_db`) holds the account, passkeys, settings, dashboards,
|
|
email accounts, queue and pipeline jobs. Schema in `src/schema/`, hand-written queries in
|
|
`src/queries/`, types inferred from the schema in `src/types.ts`.
|
|
|
|
**The filesystem** holds everything the agent authors. `OFFICER_ITEMS_DIR` contains one directory
|
|
per item under `skills/`, `tools/`, `tasks/`, `processes/`, `extensions/` — no database rows, no
|
|
scope tiers. `DATA_PATH/<email>/` holds the managed home, attachments and the per-account email SQLite
|
|
stores — those are the **email sidecar's**, and nothing in the platform opens them. Path helpers live in
|
|
`src/servers/data-path.ts`; note `getHomeDir` (the managed home under
|
|
`DATA_PATH`) versus `getOwnerHomeDir` (the owner's real login home when `HOME_DIR` is set, which is
|
|
where terminals, chats and task runs actually execute).
|
|
|
|
### Schema changes use `push`, not migrations
|
|
|
|
This database is kept in sync with `bun db:push`, which diffs the schema code against the live
|
|
database and alters it directly. **`drizzle-kit migrate` has never been run here** — there is no
|
|
`__drizzle_migrations` table. Change the schema, run `bun db:push`, done.
|
|
|
|
`bun db:gen` writes files to `officer_db/migrations/`, but nothing applies them; the old numbered
|
|
history was deleted because it had drifted from the real schema. Treat the schema code, not those
|
|
files, as the source of truth.
|
|
|
|
**Declare multi-column uniqueness as `uniqueIndex('uq_…').on(a, b)`, never `unique('uq_…').on(a, b)`** —
|
|
drizzle-kit mis-diffs named composite unique _constraints_ and re-creates them on every push, which used
|
|
to stop `db:push` on an unanswerable truncate prompt. Same for any foreign key whose generated name would
|
|
exceed Postgres's 63-character identifier limit: name it explicitly. See `src/databases/CLAUDE.md` →
|
|
"Composite keys" before adding either.
|
|
|
|
## Security Model
|
|
|
|
- `IS_DEV_BUILD` (`src/servers/build-env.ts`) is true **only** when `PUBLIC_BUILD_ENV` is explicitly
|
|
`dev`/`development`. Everything else, including unset, is hardened. Origin validation, rate
|
|
limiting and password rules all key off it — they fail closed.
|
|
- Allowed origins come from `PUBLIC_URL`. Officer always sits behind an HTTPS reverse proxy, so the
|
|
forwarded `Host` must equal `PUBLIC_URL`'s authority exactly.
|
|
- **`ALLOW_ANY_ORIGIN` defaults to ON** — origin checking is off unless the var is explicitly `false`.
|
|
A deliberate inversion of the usual rule, safe only because the perimeter is the tailnet and a valid
|
|
token is still required on every protected route. It is defence in depth that is currently switched
|
|
off, not the lock.
|
|
- JWTs are 30-day, blacklisted on signout, and invalidated by a password change (`passwordChangedAt`).
|
|
**The role is deliberately not a claim** — every authorization decision re-reads `users.role` from
|
|
Postgres, so a grant or a revoke takes effect on the next request rather than at next sign-in.
|
|
- A panic lockdown (`src/servers/api/auth/panic.ts`) is in-memory only and refuses every
|
|
authenticated request until the server restarts.
|
|
|
|
### Capabilities — read this before mounting a router
|
|
|
|
Authorization is one system, and it is not in `userMiddleware` (which only answers "is this token
|
|
valid"). It is `originScopeMiddleware` → `capabilities/authorize.ts`, mounted globally in `hono.ts`
|
|
ahead of everything, and it re-verifies the token itself so it covers routes that never mount
|
|
`userMiddleware`.
|
|
|
|
- `capabilities/registry.ts` — the single enumeration of what the platform can do, in four kinds:
|
|
`core` (every account, not deniable), `app` (**the grantable surface**), `execution` and `admin`
|
|
(owner only, and `execution` is never grantable at any level).
|
|
- `capabilities/authorize.ts` — resolves "may this account do this". Owner short-circuits first; every
|
|
other answer is role grants plus core, with `execution`/`admin` stripped even if a row grants them.
|
|
**Every catch returns deny.** Grants are cached by role and the cache's whole invalidation contract
|
|
is `invalidateRoleGrants`, called by the one writer in `api/users/capabilities-routes.ts`.
|
|
- `capabilities/totality.ts` — `assertCapabilityTotality` runs in `server.tsx` **before `serve()` and
|
|
throws**. Mount a router or a socket without a registry entry and `pm2 restart officer` fails,
|
|
naming what is missing. That is deliberate: the hole it closes was a Member 403'ing on
|
|
`GET /api/tasks` and opening `/api/tasks/pipeline/ws` with a 101 in the same minute, because Bun's
|
|
route table matches the socket before the `/api/*` catch-all that reaches Hono. A patch does not
|
|
survive the next door; refusing to boot does.
|
|
|
|
So **adding a router means adding one line to `CAPABILITIES`**. If the surface genuinely is not
|
|
user-gated, add it to `EXEMPT_API_PREFIXES` in `totality.ts` _with a reason_ — an unexplained exemption
|
|
is how the hole happened the first time.
|
|
|
|
The frontend hook `useCapabilities` **fails open** on purpose: hiding a dock icon is a courtesy, the
|
|
403 is the lock, and an owner locked out by a transient network error is worse than a member clicking
|
|
into a refusal.
|
|
|
|
## Commands
|
|
|
|
```bash
|
|
bun dev # the whole app — SPA + API + WebSockets, watched
|
|
bun start # production
|
|
bunx tsgo # typecheck (not tsc)
|
|
bun test # tests
|
|
bun format # prettier over every dirty file — see the note below before running it
|
|
bun db:push # apply the schema to Postgres
|
|
bun setup # guided install (writes .env, incl. PUBLIC_BUILD_ENV=production)
|
|
```
|
|
|
|
Sidecar control is PM2, not npm scripts: `pm2 restart officer-<name>`, `pm2 logs officer-<name>`.
|
|
See `docs/working-on-officer.md` for which process a given change needs restarted.
|
|
|
|
### Installs are frozen. Never resolve a dependency you did not ask for.
|
|
|
|
`bunfig.toml` sets `[install] frozenLockfile = true`, so **`bun install` resolves from `bun.lock` and
|
|
nothing else** — it fails rather than quietly picking up a newer version, including a transitive one
|
|
nobody chose. Verified on bun 1.3.10: a lockfile that no longer satisfies `package.json` exits 1 with
|
|
`error: lockfile had changes, but lockfile is frozen`.
|
|
|
|
It is config rather than a habit because a supply-chain compromise does not wait for the one time
|
|
somebody forgets a flag. On **2026-08-04** eleven cache packages — `keyv`, `flat-cache`,
|
|
`file-entry-cache`, `cacheable-request`, `cache-manager`, the `@cacheable/*` scope, `ecto` — were
|
|
published with a `preinstall` dropper that harvested npm and GitHub tokens, AWS and Kubernetes
|
|
credentials, SSH and PEM keys, `.env` files and `.claude/settings.json`, then republished itself
|
|
through any npm token it found. It reached 434 further packages across 1,381 versions. This machine was
|
|
unaffected only because nothing had installed since 2026-08-02.
|
|
|
|
**To change a dependency:** edit `package.json`, run `bun install --no-frozen-lockfile` deliberately,
|
|
**read the lockfile diff**, and commit it. The friction is the point — an unexplained lockfile change
|
|
in a diff is the signal this exists to produce.
|
|
|
|
**Do not** add `--no-frozen-lockfile` to a script, a Dockerfile or CI to make an error go away. The
|
|
error means the lockfile and `package.json` disagree, and that is worth a human look every time.
|
|
|
|
**Format your own files, not the whole dirty tree.** `bun format` globs `git diff --name-only HEAD`,
|
|
so it rewrites every uncommitted file — including work in progress that isn't yours, which then shows
|
|
up as unexplained whitespace churn in someone else's diff. Run `bunx prettier --write <paths>` on the
|
|
files you actually touched. `bun format` is only safe when the tree is otherwise clean.
|
|
|
|
## Code Style
|
|
|
|
- **Paradigm**: functional — pure functions, immutability, composition
|
|
- **TypeScript**: strict, no `any`. Type-only imports are required (`verbatimModuleSyntax`).
|
|
- **Comments**: minimal, and about _why_. Don't narrate what the code already says.
|
|
- **Async**: always async/await
|
|
- **Exports**: named only, no defaults
|
|
- **Files**: `PascalCase.tsx` for components, `kebab-case.ts` for everything else
|
|
- **Prettier**: single quotes in JS, double in JSX, semicolons, trailing commas, 120 cols
|
|
|
|
### Imports
|
|
|
|
Order: types → external → workspace → relative.
|
|
|
|
```ts
|
|
import type { User } from 'types';
|
|
import { useState } from 'react';
|
|
import { useQuery } from '@tanstack/react-query';
|
|
import { useClient } from 'hooks/useClient';
|
|
import { formatDate } from 'helpers/formatters';
|
|
import { useWebsites } from '../useWebsites';
|
|
```
|
|
|
|
In app code import types from `'types'`, which re-exports the database types. Only server and
|
|
database code imports from `officerdb/types` directly.
|
|
|
|
### Functions
|
|
|
|
Arrow functions for one-liners, regular functions for anything with real logic. No multiline
|
|
parameter lists — extract a params type instead.
|
|
|
|
```ts
|
|
type ProcessDataParams = { body: Record<string, unknown> | undefined; query: Record<string, string> };
|
|
export function processData({ body, query }: ProcessDataParams): Result { ... }
|
|
```
|
|
|
|
### Components and hooks
|
|
|
|
```tsx
|
|
type ExperimentCardProps = { experiment: Experiment; onSelect: (id: number) => void };
|
|
|
|
export const ExperimentCard = ({ experiment, onSelect }: ExperimentCardProps) => { ... };
|
|
```
|
|
|
|
Complex hooks return objects; simple state hooks return `as const` tuples. Event parameters are
|
|
always named `ev`, never `e`.
|
|
|
|
### API calls
|
|
|
|
`useClient()` returns typed verbs — `client.get<Foo>('/foo')` really does return `Foo`, so pass the
|
|
type parameter and let inference flow from it.
|
|
|
|
## Working With Me
|
|
|
|
- **Ask first** — confirm the approach before a significant change
|
|
- **Explore thoroughly** — read the related files before editing
|
|
- **Keep it simple** — no over-engineering, no premature abstraction
|
|
- **Be explicit** — no magic, no implicit behaviour
|
|
- **Stay focused** — note unrelated problems, don't fix them uninvited
|
|
- **Report honestly** — say what you verified and what you didn't
|
|
|
|
**Always commit and push when you finish implementing — including when it went wrong.** Don't wait to be
|
|
asked, and don't hold a branch back because it is unfinished, untested or turned out to be a dead end. The
|
|
history of the mistakes is worth having: a reverted commit and its message explain why an approach was
|
|
abandoned, which is exactly the thing that gets lost when a failed attempt is quietly discarded. Say what
|
|
state it is in — in the commit message and in `COMMS/` if another agent will pick it up — rather than
|
|
withholding the commit until it is good.
|
|
|
|
Commit messages: simple lowercase, no prefixes.
|
|
|
|
## Frontend route conventions (apply to EVERY new dashboard route)
|
|
|
|
Worked examples: `/soulseek`, `/music`, `/chat`.
|
|
|
|
- **Workspace/Panel framework, always — never a standalone single screen.** The screen renders
|
|
`<WorkspaceView workspace={ws} locked />` where
|
|
`ws = useDashboardState<LayoutNode>('screens/<name>', defaultLayout)`, guarded by a `normalizeLayout`
|
|
that pins `appType`s to an allow-list. Panels are windowed apps under
|
|
`src/workspaces/officerdev/src/apps/<Feature>/`, each exporting `appRegistryMetas`
|
|
(`{ key, name, icon, component, availableOnPanel: false }`) and registered in `AppRegistry.tsx`.
|
|
The Workspace/Panel framework is orthogonal to routing — it contains no route navigation, and panels
|
|
live inside the Route element tree, so they can call `useParams`/`useSearchParams` directly.
|
|
- **Page title by route.** Add a rule to `RULES` in `src/apps/officer-web/state/usePageTitle.ts`
|
|
(`{ match: (p) => p.startsWith('/<name>'), title: '<Name>' }`, most-specific first);
|
|
`usePageTitleSync` does the rest. `startsWith` means nested routes are covered.
|
|
|
|
### The URL is the source of truth for selection
|
|
|
|
`docs/navigation-audit.md` is the authority here — read it before building a screen that selects
|
|
things. It names the **"opaque click" anti-pattern**: an element that opens something addressable but
|
|
keeps the id in an onClick closure instead of the DOM, leaves the URL unchanged, holds the selection
|
|
in `usePanelChannel`/`useGlobal`, and has no anchor semantics (no cmd-click, no middle-click, not
|
|
link-focusable). Half the app still does this; none of the new code should.
|
|
|
|
- **Addressable state goes in the URL** (`useParams` / `useSearchParams`), never in a channel.
|
|
`usePanelChannel` is for genuine signals and refresh buses (`files:refresh-signal`,
|
|
`SLSKD_REFRESH_CHANNEL`, `MUSIC_RESYNC_CHANNEL`) — and declare one with `defineChannel` in
|
|
`officerdev/src/channels.ts` rather than spelling the name and type at each site.
|
|
"Which thing is open" is a URL. Panels each read the URL rather than passing it between themselves.
|
|
- **Rows and nav items are real links.** `<Link>` for rows (exemplar: the `/chat` session list,
|
|
`f35c145`); **react-router's `<NavLink>`** for nav chrome, so active state comes from the router.
|
|
The hand-rolled `isActive` in `Dock`/`Header` is scheduled for replacement (audit Phase 4) — don't
|
|
copy it. A disabled entry renders as a `<span>`; a disabled `<a>` is not a thing. A control that
|
|
_mutates_ rather than navigates stays a `<button>`.
|
|
- **Route pairs.** A bare screen route plus a param route rendering the same component: `/chat` +
|
|
`/chat/:sessionId`, `/jobs` + `/jobs/:id`, `/email` + `/email/:emailId`, `/headscale` +
|
|
`/headscale/:section`. One `<Navigate … replace />` guard in the screen, placed after all hooks,
|
|
canonicalises both the bare route and a bogus param.
|
|
- **Master list + live preview uses `?selected=<id>`**, not the detail route — linking rows straight
|
|
to `/x/:id` destroys preview-on-list, because that route is the full page. Rows link to
|
|
`/x?selected=id` on-page and `/x/:id` off-page; list, mobile panel and preview all read the param.
|
|
Action buttons are **siblings** of the anchor, never nested inside it.
|
|
- **`src/workspaces/components/NavLink.tsx` is not react-router's `NavLink`** — it's a `<Link>`
|
|
wrapper that appends `useGlobalQueryString()` and provides no active state. Import react-router's
|
|
when you want `isActive`.
|
|
- Route helpers shared between an `officerdev` panel app and an `officer-web` screen must be
|
|
re-exported from `src/workspaces/officerdev/src/index.ts` (named exports only — the barrel
|
|
deliberately avoids `export *` for app modules to keep `appRegistryMetas` from colliding).
|
|
|
|
## COMMS — a channel between agents, when one is open
|
|
|
|
`COMMS/<work-stream>/` is a **tracked** channel between agents working on this repo from different machines.
|
|
It exists because findings used to reach each other by the owner relaying them from memory at the end of long
|
|
sessions.
|
|
|
|
**There is no open channel right now.** `COMMS/sidecar-app-store/` ran for one night — per-user Linux
|
|
accounts through to a member's first agent turn — and was deleted when the work landed, which is the
|
|
convention rather than an oversight: a spent channel left in place gets read as current.
|
|
|
|
If you open one:
|
|
|
|
- **Read it before starting**, if your task touches its work stream. It carries what is verified, what is
|
|
assumed, what is broken, and what is waiting on a decision — the parts a commit message does not hold.
|
|
- **Number the files and alternate**, one per turn, odd for one agent and even for the other. The parity is
|
|
the author; the alternation is the protocol. A push with no doc is then visibly a break rather than
|
|
something to find by diffing, and "nothing to report" is still a turn worth taking — silence and a crashed
|
|
agent read identically.
|
|
- **End on a checkable condition**, not on either party's judgement: no open item is actionable by a
|
|
participant. "I think we're done" can close a thread with work still in it.
|
|
- **Durable reasoning goes in `docs/` or next to the code.** The channel is for coordination. When the work
|
|
lands, delete the channel and move anything still open to `TODO.md`.
|
|
|
|
Two things that made it work, and neither is about either agent being more careful. One writes, the other
|
|
verifies, and only the verifier runs things on a real machine — most of what was caught was invisible to
|
|
reading and needed a live filesystem. And the author of a comment is the worst-placed person to notice the
|
|
code disagrees with it: the two most serious defects were both found by whoever had not written the sentence
|
|
explaining why it was safe.
|
|
|
|
## Further Reading
|
|
|
|
- `docs/navigation-audit.md` — **authoritative** on routing/navigation: the opaque-click anti-pattern,
|
|
a severity-ranked findings table, the channel-selection map and the four-phase plan
|
|
- `docs/agent-coordination.md` — **the north star** for the workspace/panel work: agents on one
|
|
dashboard coordinating with each other instead of through the human, the handoff protocol, and what
|
|
is deliberately _not_ being built. Read it before ranking, deferring or starting any panel item —
|
|
it is what `docs/workspace-panel-todo.md` is ranked against.
|
|
- `docs/workspace-panels.md` — how the Workspace/Panel framework works: the layout tree, how a panel is
|
|
mounted and what it knows, the URL-vs-channel split for panel-to-panel communication, and the
|
|
persistence key families. Read before building a panel app. Its defect list is
|
|
`docs/workspace-panel-todo.md`.
|
|
- `docs/secret-store.md` — **design, not built**: moving the encryption and signing keys out of `.env`
|
|
into a SQLite store, why they cannot live in Postgres, and key rotation. Also records the core/plugin
|
|
split it assumes — light plus `officer-headscale` is the core; Vaultwarden and the wallet are plugins
|
|
- `docs/sidecar-topology.md` — where the sidecar architecture is going, and what was considered and dropped
|
|
- `docs/working-on-officer.md` — how to run, restart and check your work on this machine
|
|
- `docs/wallet-key-custody.md` — what the platform can and cannot see of the wallet
|
|
- `TODO.md` — current direction and deferred work; **takes precedence over this file where they disagree**
|
|
- `src/apps/CLAUDE.md` — shared frontend patterns
|
|
- `src/databases/CLAUDE.md` — database patterns
|
|
- `CONVENTIONS.md` — component organisation, state management and React patterns, with rationale;
|
|
`src/workspaces/officerdev/APP_CONVENTIONS.md` and `HOOK_CONVENTIONS.md` for panel apps and hooks
|
|
|
|
Treat the code as the source of truth where anything here disagrees with it.
|