# AGENTS.md This repository owns the Compose Preview server implementation, its `serve-web` frontend, Wasm UI, and visual harness. Read `README.md` first. Before changing a module boundary, read `docs/design/UI_BUILDER_PROJECT_BOUNDARY.md`, which is the one that states and enforces the current boundaries. ## Enforced rules - Git history attributes work only to the human committer. Never add an AI `Co-authored-by:` trailer or use an agent identity as author/committer, in a commit or in a PR title or body. Exactly two things are rejected, and [`agent-attribution-scan.sh`](.github/scripts/agent-attribution-scan.sh) is the definition: an agent identity as a commit's **author or committer**, and a `Co-authored-by:` **trailer** naming one. Deliberately **not** rejected, and not to be scrubbed: a `claude.ai/code` session link, the "🤖 Generated with …" and `_Generated by [Claude Code]_` footers, ordinary prose, and PR comments. None of those claims authorship. - **Human reviewers: verify before reporting an attribution finding.** Automated reviewers do not report attribution at all; see [Review guidelines](#review-guidelines). It is the most-reported and least-real finding on this repository, and the reports keep naming commits that do not exist here. Never infer the identity from the fact that an agent wrote the code — it is whatever `git config user.email` held, and here that is the human. Check first: git cat-file -t # a commit you are about to name must actually exist git log --format='%h %an <%ae> | %cn <%ce>' .. .github/scripts/agent-attribution-scan.sh --range '..' `` must be the pull request's head commit as GitHub reports it: a commit made in a review checkout, for example by reapplying the diff, carries the reviewer's identity, and that is how every false report so far arose. Report only what the scanner exits 1 on, and quote its output. `Yuri Schimke ` is this repository's human identity, and `github-actions[bot]` / `renovate[bot]` are exempt bot accounts — none of the three is ever a finding. If the `Reject agent attribution` check is already green on the head commit, there is nothing to report. - Branch names use `agent/...`. - Commit subjects and PR titles use Conventional Commits. A `!` in one does **not** bump the major: every release here is a minor, and why is in [`docs/VERSIONING.md`](docs/VERSIONING.md). - Run `./gradlew ktfmtFormat` before committing Kotlin changes and `npm --prefix serve-web run format` before committing serve-web changes. `ktfmtFormat` is the only formatter that is correct by construction here: a standalone `ktfmt --google-style` preserves a hand-broken lambda that the Gradle plugin collapses, so it can produce files CI rejects, and the failure reads like a stale checkout rather than a formatter disagreement (#822). - `:ui-builder-runtime`'s public API is pinned by a committed ABI dump that `checkKotlinAbi` verifies in its own repository's `check` — not in this one, and that is the cost of the split: an API change there is a release, then a catalog ref bump here. `:server` stays off the gate on purpose; its build file says why. - Regenerate the committed goldens with `scripts/regenerate-goldens.sh`, and read the diff. On a Renovate branch the `Regenerate goldens` workflow does it for you when CI goes red; on any pull request `/regenerate-goldens` asks for the same thing. - Immediately before every push, fetch `origin main` and confirm the branch or PR has not merged. - Open or update a PR automatically after a completed coding change. Never auto-merge. - **Embed images only from a GitHub-hosted origin — including images this server serves.** Claude Code on the web silently rewrites `![alt](url)` to `[alt](url)` on the way to the API whenever the destination is not a GitHub host, so the picture lands as a bare link and the API still returns 201. Kept: `raw.githubusercontent.com`, `github.com///raw//…`, `github.com/user-attachments/assets/…`, and the `user-images` / `private-user-images` / `avatars` / `objects` / `media` / `gist` `.githubusercontent.com` hosts. Stripped: everything else, **`preview.coo.ee` included** — so a PR here that shows off a rendered page must point at the committed PNG under [`docs/evidence/`](docs/evidence/) or [`docs/images/`](docs/images/) through a commit-pinned `raw.githubusercontent.com` URL, never at the deployment. Measured variant-by-variant on [#456](https://github.com/yschimke/compose-preview-server/issues/456); upstream it is [anthropics/claude-code#89540](https://github.com/anthropics/claude-code/issues/89540) (open, `area:security`), an anti-exfiltration control rather than a bug to route around. The rewrite is a blind regex — it fires inside code spans and fenced blocks, and an `` is HTML-escaped into a code span — so no spelling of a non-GitHub host renders. `preview.coo.ee` remains the right thing to **link** to in prose; it is only image embeds that need the GitHub origin. - In a PR body write `![alt](url)` plainly, and **leave any backticks that appear around the URL alone**. They are injected between an agent and GitHub rather than authored, and the [`PR Body Syntax`](.github/workflows/pr-body-syntax.yml) workflow strips them in place within a minute of the edit. Hand-fixing or re-posting to "correct" them does not work — the next write is mangled the same way. ## Review guidelines - **Do not report commit author, committer or `Co-authored-by:` findings.** Your review sandbox applies the pull request as a new commit under your own identity (`Codex `), so any identity you read from a local `git log` is yours, not the author's. Every such finding on these repositories has named a commit that exists only in the sandbox. Attribution is a human and CI concern; leave it out of reviews entirely. This overrides the human reviewer guidance on attribution elsewhere in this file. ## Running Gradle Wrap Gradle in [`build-brief`](https://bb.staticvar.dev) ([`static-var/build-brief`](https://github.com/static-var/build-brief), MIT, a single Go binary with no runtime dependencies). It writes every line Gradle emits to a log file and prints only what decides the next move — status, failed tasks, failed tests, warnings, build scan URLs, generated output paths, artifacts — and passes Gradle's exit code through unchanged, so it is safe anywhere a bare `./gradlew` was. ``` brew install static-var/tap/build-brief # or: curl -fsSL https://bb.staticvar.dev/install.sh | bash build-brief doctor # read-only; never runs Gradle build-brief ./gradlew check ``` This repository is a good fit for it: `check` here drags in `ktfmtCheckAll`, the `:server:` boundary and JVM-floor checks and the wasm-ui test lanes, and the one line that says which gate rejected you is otherwise buried. A failure prints the raw log path — open that when the brief is not enough. On a shared developer host, automated builds use [`scripts/agent-gradle.sh`](scripts/agent-gradle.sh) instead of invoking `build-brief` directly: ``` scripts/agent-gradle.sh :server:test --tests '*ServeCommandOptionsTest*' scripts/agent-gradle.sh --exclusive check ``` The launcher still goes through `build-brief`, but gives automation a four-worker ceiling, low process priority, non-interactive input and a ten-minute Gradle-daemon idle timeout. Use the normal profile for focused compilation, formatting and tests. Use `--exclusive` for `check`, distribution builds, Wasm executable links and other broad Gradle task graphs: it takes one per-user machine lock shared by every worktree of the Compose Preview server, daemon and tools repositories, so two automated compiler daemons cannot peak together. The lock deliberately does not cover direct `./gradlew` or `build-brief` invocations, so interactive development stays responsive, and hosted CI keeps its runner's full capacity. Do not copy these limits into the repository's `gradle.properties`; that would throttle those two cases as well. Two local notes. Report-style commands (`tasks`, `help`, `projects`, `dependencies`, `dependencyInsight`) keep their full bodies, so dependency debugging is unaffected. `--ci` is opt-in per job and never inferred; the workflows here call Gradle directly and none of them depend on the reduced form. The per-command rules live in the managed `build-brief` block at the end of this file; `build-brief --install` regenerates it, so edit it there rather than by hand. Wrapping changes none of the rules above: `ktfmtFormat` and `scripts/regenerate-goldens.sh` are still how those artefacts are regenerated, just run through `build-brief`. ## Boundary rules - Default builds resolve released coordinates from Maven Central. Do not add `mavenLocal()`, a composite include of `compose-ai-tools`, project substitution, or a shared catalog outside this repository. The two opt-in properties `settings.gradle.kts` already defines are the exception and the only one: `-PlocalBuilds=tools,daemon,contracts` and `-PcomposeUiBuilderDir=` swap a released coordinate for a sibling checkout, unset nothing changes, and the default build — so the release — proves the published set resolves. For user-authorized local prototyping without a checkout to follow, use the staged-publication workflow in [`docs/development/LOCAL_DEPENDENCIES.md`](docs/development/LOCAL_DEPENDENCIES.md). - The UI builder is a separate repository, [`yschimke/compose-ui-builder`](https://github.com/yschimke/compose-ui-builder), consumed as RELEASES. Its three jars and a BOM come from Maven Central at `composeai-ui-builder` in `gradle/libs.versions.toml`, and the editor archive from that repository's GitHub release through the group-fenced ivy repository in `settings.gradle.kts`. Which modules are seams, and the rule that the builder never depends on the server, are written once in [`docs/design/UI_BUILDER_PROJECT_BOUNDARY.md`](docs/design/UI_BUILDER_PROJECT_BOUNDARY.md) (the authoritative copy is in that repository). A change to this repository's consumption of the builder moves the catalog ref; changing the builder itself is a pull request there, and a change spanning the two needs a release of that repository first. - Which repository a module belongs in is decided by the layer rule, written once in [`docs/design/REPOSITORY_LAYERS.md`](https://github.com/yschimke/compose-ai-tools/blob/main/docs/design/REPOSITORY_LAYERS.md): contracts is shape, compose-ai-tools is offline behaviour, this repository is HTTP and the surfaces reachable over it, and a dependency may only point down. Wire shapes therefore belong in `compose-preview-contracts`; server behavior and browser/offline scoring implementation belong here. Moving implementation into the contracts repository does not reduce traffic coupling. - The Gradle Tooling API stays off this repository's floor. A server that needs a local Gradle build asks compose-ai-tools for one across a process boundary, over a contract in `compose-preview-contracts`; it does not link a Gradle driver ([#9](https://github.com/yschimke/compose-preview-server/issues/9), [#180](https://github.com/yschimke/compose-preview-server/issues/180)). - Three shipping Kotlin modules. `:server` holds the HTTP layer, the runner, the catalog store and the web surfaces; `:mcp` is the Model Context Protocol server; `:usage-source-psi`, `:wasm-ui` and `:native-catalog-m3` are the supporting build modules. The UI builder's service — `:ui-builder-runtime` — is not here: it is a released coordinate from `yschimke/compose-ui-builder` that `:server` links, and it holds persistent design state, catalog validation and renderer-neutral export orchestration there. This repository publishes no Maven coordinates; the modules ship inside the standalone server archive. `checkServeModuleBoundary` and `checkServerJvmFloor` enforce the graph and the floor. - `:mcp` is the third, and it depends on neither of the other two. It is the Model Context Protocol server — `compose-preview mcp serve` — moved here from compose-ai-tools because the layer rule places a module that needs an HTTP server in this repository (compose-ai-tools#5176), and it reaches what it needs from layer 1 (`daemon-core`, `daemon-client`, `render-session-api`, `render-matrix`) as published coordinates. It ships as the `compose-preview-mcp` standalone tarball on the GitHub release, which is what the CLI's `mcp` command launches. Keep `checkMcpToolingApiBoundary` passing: driving a Gradle build is layer-1 behaviour and stays off this repository's floor, MCP included. - `:render-host` was a third, until #180 moved it to compose-ai-tools, where it publishes as `ee.schimke.composeai:render-host`. It is offline behaviour that opens no socket, which the layer rule places in that repository, and it had zero project dependencies inside this build. `:server` still depends on it; the edge points the same way, it just crosses a repository boundary the correct direction now. `checkRenderHostIsServerFree` went with it. - Keep `checkServeModuleBoundary` a resolved-classpath positive allowlist, including transitives. - Shared-source ownership and update procedures live in [`docs/design/SHARED_SOURCE_OWNERSHIP.md`](docs/design/SHARED_SOURCE_OWNERSHIP.md). The slot runtime is consumed from compose-preview-daemon, the tools-owned PSI parser is a commit-pinned vendor checked in CI, and `wasm-ui` remains the upstream for compose-ai-tools' catalog-specific pinned fork. Do not reintroduce local slot sources or update a vendored surface without its pin/gate. - The preview-selector rule (`previewIdMatchesStandaloneRequest`) is stated in this repository and again in compose-ai-tools, because `serve` is a launcher and the CLI no longer passes its own rule in. `docs/serve/preview-selector-fixtures.json` is the shared golden table that pins them; it is owned upstream, vendored by `scripts/sync-preview-selector-fixtures.sh`, and run by `PreviewSelectorFixturesTest`. Change the rule, change the table upstream in the same change. - The UI-builder design-guideline rules, prompt and picture plan are compose-ui-builder's (`docs/guidelines/android-design-guidelines.json`, embedded in `ui-builder-export` as `DesignGuidelineRuleSet.Bundled`). Nothing is vendored here: change them upstream, and they arrive with the next `composeai-ui-builder` bump. - A test here of behaviour compose-ui-builder owns — which guideline rules a design is asked, what an export emits or refuses — asserts that the server delegates (its result equals the library call's) plus the invariants the server relies on, never the builder's current output restated. A restated output turns the next `composeai-ui-builder` bump red on a correct builder change (yschimke/compose-ui-builder#574 did; #1401 fixed another), and then the bump waits on a test fix here. - Two JVM floors, `java-server` (17) and `java-ui-builder` (21), declared once in `gradle/libs.versions.toml` with the reasoning beside them. Everything this repository compiles or resolves against is 17, because compose-ai-tools' `:cli` compiles against the released server on a 17 toolchain. The 21 floor is the UI-builder frontend's, in its own repository; it reaches this build only as data inside `compose-preview-ui-builder-render-bundle`'s polyglot PNG, which the startup preflight reads. Building the `visual-harness` lane still needs both JDKs. Never raise a module's toolchain by editing the module — `:server:checkServerJvmFloor` scans the resolved distribution classpath and will say so. - **Never fabricate a component in the Wasm canvas to stand in for a library the canvas cannot link.** The editor's canvas is Compose Multiplatform for Wasm; `androidx.wear.compose` is an Android AAR it can never link, and hand-assembling a lookalike out of Material 3 pieces produces an impression nothing in this build can check. A catalog whose components the canvas cannot draw gets its fidelity from the streaming preview lane, which compiles the generated Kotlin against a real classpath — not from a replica maintained here. Stated once, with the decision and what it costs, in [`docs/design/UI_BUILDER_WEAR_SCREEN.md`](docs/design/UI_BUILDER_WEAR_SCREEN.md#the-line-a-component-is-never-faked-so-it-can-run-in-wasm) ([#395](https://github.com/yschimke/compose-preview-server/pull/395) is the change it closed). - The source package stays `ee.schimke.composeai.cli.serve` until a separately reviewed rename. - UI-affecting PRs include viewable before/after evidence and update the visual harness when needed. ## The hosted catalog MCP server [`.mcp.json`](.mcp.json) registers `compose-preview-catalog`, the deployment of this repository's own `:mcp` module at `https://preview.coo.ee/mcp`. It is how an agent reads the hosted catalogs and drives a UI-builder design — `ui_builder_get_design`, `ui_builder_apply`, `ui_builder_export`, `ui_builder_render_native` — against the running server rather than a local one. The credential is `$COMPOSE_PREVIEW_TOKEN`, sent as `X-Compose-Preview-Token`. With the variable unset the header is empty and every call answers `authorization_required`; that is the normal cold start, not a misconfiguration. Recover it through the server's own device-code flow: call `request_access` with the scope and capabilities the task needs (`ui-builder-read`, `ui-builder-write`, `ui-builder-export` are separate from the `preview -> live -> playground` compute ladder), show the human the `approveUrl` and `userCode` it returns, then `poll_access` until it answers `approved`. Then **pass that token as each gated tool's `token` argument**: your MCP client fixed its headers when it connected, so a token approved mid-session cannot be put on one, and the argument is what makes access you just obtained usable in the session that obtained it. Setting `$COMPOSE_PREVIEW_TOKEN` before the session starts remains the way to skip the flow. A server restart drops every grant, so a token that stopped working is asked for again the same way. Designs are private to their owner and collaborators, so a grant reads only what its actor has been given an ACL for. ## build-brief - Prefer `build-brief gradle ...` for PATH Gradle and `build-brief ./gradlew ...` for the project wrapper. - For chained shell commands, rewrite each Gradle segment individually, for example `build-brief gradle test && build-brief gradle check`. - Use default `build-brief` output for routine Gradle work; it stays intentionally short on clean success cases. - Use default `build-brief` output for report-style commands like `tasks`, `help`, `projects`, `dependencies`, and `dependencyInsight`; their report bodies are preserved. - Use `build-brief gradle --stacktrace ...` or `build-brief ./gradlew --stacktrace ...` when you need Gradle stack traces. - `build-brief` normalizes output-shaping flags like `--quiet`, `--warn`, `--warning-mode ...`, and `--console ...` so its reducer keeps working reliably. - Let Gradle daemon reuse happen by default; `build-brief` strips explicit `--daemon` and `--no-daemon` overrides rather than forcing daemon-off behavior. - Preserve the raw log path from `build-brief` output when handing build failures to another tool or agent.