# CLAUDE.md This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. ## What this is Radial coordinates humans and coding agents on software goals. A human writes a goal and requests a typed **artifact** against it (plan, implementation, review, ADR); a daemon dispatches an agent in a container and the result lands as a signed atproto record. There is **no central server** — the web UI and the daemon each ingest the same records from members' PDSes and independently fold them into the same view. `docs/design.md` is the authority on the protocol (trust, data model, turn model, forges, security, and rejected alternatives) and its section numbers are cited throughout the source. Read the relevant section before changing anything in `core`'s fold or the lexicons. ## Commands Node 24, pnpm 10. Run from the repo root. ```sh pnpm install --offline pnpm codegen # regenerate core/src/generated/records.ts from packages/lexicons/lexicons/*.json pnpm typecheck # tsc --noEmit in every package pnpm lint # typecheck + core's type-strip lint + `generate.mjs --check` (generated file is current) pnpm test # builds each package, then runs its tests pnpm build pnpm images # build the agent turn container image (docker/Dockerfile) ``` CI runs `codegen` then `git diff --exit-code`, so a lexicon change with stale generated output fails the build. The same steps run on both remotes — `.github/workflows/ci.yml` on GitHub and `.tangled/workflows/ci.yml` on tangled's spindle; change one and change the other. Deployment is spindle-only: `.tangled/workflows/deploy.yml` publishes the UI bundle to Cloudflare Pages on a push to `main` (`packages/ui/README.md` covers the origin and the secrets). ### Running one test **Tests import from `dist/`, not `src/`** — build the package first: ```sh pnpm --filter @radial/core build node --test packages/core/test/materializer.test.mjs node --test --test-name-pattern 'claim' packages/core/test/materializer.test.mjs pnpm test:watch # core only: rebuild + re-run on change ``` Per-package: `pnpm --filter @radial/daemon test`, `--filter @radial/ui dev`, etc. `@radial/ui` is the exception with two runners: `node --test test/*.test.mjs` (bundle/serve assertions) plus `vitest run`, which is itself two projects — `pure` (Node, no DOM) over `src/**/*.test.ts`, and `components` (jsdom, `svelte`'s browser condition) over `src/**/*.svelte.test.ts`, which mount CodeMirror for real. Run one with `vitest run --project pure`. ## Package graph Dependencies point one way; nothing depends on `daemon` or `ui`. | Package | Role | |---|---| | `lexicons` | `com.disnetdev.radial.*` JSON schemas — the wire contract, source of truth | | `core` | The fold: `materialize()` records → index, plus `RecordStore`, `timeline()`, `bundle`, validation, claim-lease rules. **Isomorphic** | | `atproto` | Zero-dependency XRPC/repo-read client | | `ingest` | Repo polling and Jetstream subscription, per-space ingestion into a `RecordStore` | | `sidecar` | `radial` CLI. `runCli()` is the single write path for *both* the CLI and the web app | | `daemon` | `radiald`: sync runtime, claims, dispatch, containers, forge adapters, checks, merge polling | | `ui` | SvelteKit SPA — a second full implementation of Radial, not a client of one | ### `core` is isomorphic; `core/node` is not The main entry must stay free of `node:*` so a browser tab runs the identical `materialize()` the daemon runs. Node-only implementations (`SqliteRecordStore`) live behind the `./node` export. `packages/ui/test/browser-bundle.test.mjs` fails if a `node:*` import creeps onto the browser path. `ingest`, `sidecar`, and `atproto` follow the same `./node` split. ### Generated types `packages/core/src/generated/records.ts` is written by `packages/lexicons/scripts/generate.mjs`. Never hand-edit it; change the lexicon JSON and run `pnpm codegen`. `packages/core/src/records.ts` re-exports it and adds the hand-written record helpers. ## The daemon loop `SpaceSyncRuntime` (`runtime.ts`) keeps SQLite record + checkpoint stores open, syncs, and materializes; `TurnDispatcher.pump()` (`dispatch.ts`) then launches eligible turns with bounded concurrency and a durable turn ledger. An **assigned** request dispatches directly; an **open** one requires writing a `claim`, ingesting it back, and winning the deterministic tie-break first (`claims.ts`, `claim-ledger.ts`) — that confirmation wait is what bounds duplicated work between two operators to one ingestion cycle. A turn is one container run producing exactly one terminal record, fixed before the container starts by the request's type: an `artifact`, a `review` verdict, or a thread `message`. The container holds a forge token and a model key and **never a protocol credential** — the in-container `radial` sidecar forwards over a socket to `turn-socket.ts`, and the daemon does the validation, anchoring, signing, and PDS write. `packages/daemon/README.md` covers claims, forges, credentials, transports, and turn types. Forges are a **registry keyed by the project's `gitUrl` host** (`forge.ts`), not one global switch: a space may hold a GitHub project and a tangled project at once. GitHub is observation-only (turns push and open PRs themselves with `gh`); tangled pushes over ssh and the *daemon* opens the pull request, because tangled ships no user-facing CLI. ## Invariants worth knowing before you edit - **Lexicon changes are additive-only.** Records are already live in people's repos. A field that cannot be added to already-signed records must not become `required` — see the `artifact.title` discussion in `packages/lexicons/README.md`: the obligation lives in the writers instead. - **Any new record type or index rule lands in `core` with permutation coverage** — every arrival order must produce the same view — before the daemon or the UI consumes it. The shared fixture is `packages/core/test/scenario.ts` (`goldenScenario()`); `materializer.test.mjs` shuffles it. - **The fold cannot read observer-local state.** Repo revisions and first-sighting stamps are ingestion metadata, not provenance. Claims are the one deliberate exception (a lease is a duration measured from first sighting — `claim-lease.ts`), and nothing convergent, including the digest, reads it. - **Primary record + typed overlay records.** Nothing edits a primary record: a goal's ending is a `closeGoal`, a project's rename an `editProject`, an auto-review toggle a `setAutoReview`. In-place edits are flagged as edit annotations, not adopted (the sanctioned exceptions are claim renewal and an agent profile's republish). - **The daemon never authors an `answer` request.** Auto-review is the only daemon-authored hop. `daemon/test/auto-review.test.mjs` asserts this over the source: exactly one `create(COLLECTIONS.artifactRequest)` exists under `src/`, and it builds a `review`. - **Reviews annotate, never gate**, and Radial never merges. Anything the UI or daemon acts on mechanically must be a typed record — never a parsed message body. - **No third-party runtime or test dependencies** outside `@radial/ui` (and `esbuild` for builds). Tests are `node --test` `.mjs` files. Packages compile with `"types": []` and hand-written `src/node-shims.d.ts` declarations rather than `@types/node` — extend the shim when you use a new Node API. `tsconfig.base.json` is strict, including `noUncheckedIndexedAccess` and `exactOptionalPropertyTypes`. - **`127.0.0.1`, never `localhost`** for anything serving the UI: atproto's loopback OAuth redirect must be an IP, and the other name is a different origin holding a different IndexedDB. - **A tangled round must be WHOLE, and nothing downstream will tell you it isn't.** A tangled pull is a patch, not a ref: `forge-tangled.ts` builds each round with `git format-patch`, which omits merge commits and still exits 0 with an empty stderr. A round that lost a commit surfaces as "this pull request has conflicts" against a branch that may merge as a fast-forward — so the tangled implementation prompt rebases where GitHub's merges, and `#formatPatch` counts the range and refuses rather than publishing a round that cannot apply. Keep those two halves in step. - **No NUL bytes in a text file.** Git classifies any file containing one as binary, and a binary diff has no body — which breaks patch-based forges and any review that reads a diff. Write `\u0000`, never the raw byte; `scripts/check-no-nul.mjs` runs first in `pnpm lint`. ## Docs | | | |---|---| | `docs/design.md` | The protocol design; source comments cite its section numbers | | `docs/plan.md` | Phased implementation plan and what is out of scope | | `docs/running-an-agent.md`, `docs/operators.md`, `docs/radial-json.md` | Operator walkthrough, multi-operator concerns, full `radial.json` reference | | `docs/adr-*.md` | Decision records (tangled forge, claim leases as durations) | | `PRODUCT.md`, `DESIGN.md` | Who this is for; the UI design system as shipped | | `packages/*/README.md` | Per-package detail — `ui`'s maps every `src/lib` module |