# CLAUDE.md Working notes for Claude Code on pansy. [DESIGN.md](DESIGN.md) is the architecture document and stays authoritative; this file is the operational stuff you'd otherwise rediscover every session. ## This is a vibe-coded project — say so **pansy is written by an LLM, with a human directing.** That is not a footnote, it is a property of the software people should know before they trust it with their garden plans. Rules, not preferences: - The README carries a prominent, near-the-top disclosure. It does not get moved below the fold, softened into "AI-assisted", or quietly dropped in a rewrite. - If you rewrite the README, the disclosure survives the rewrite. - Anywhere else the project introduces itself (a landing page, a docs site, a package description), it says the same thing. If you find yourself editing that section, the only acceptable direction is clearer and more honest, never quieter. ## Keep the docs true Docs rot silently and nobody notices until someone follows them and it doesn't work. Treat them as part of the change, not follow-up work: - **README.md** — update it in the *same commit* whenever you add or change an environment variable, a route worth knowing about, a build or run command, or anything in the Docker/Compose example. The env var table and the compose snippet are the two things people copy, so they're the two that hurt most when they're stale. - **DESIGN.md** — update it when the architecture actually changes: a new table, a new package, a new API surface, a decision that supersedes one written there. Not for every implementation detail. - **CLAUDE.md** — this file. Add a convention here the moment you find yourself rediscovering it. - **Examples must run.** If you change something an example depends on, fix the example. A snippet that references an issue number as "once #5 lands" after #5 has landed is a bug in the docs. When you touch a file, glance at whether the comments around your change are still true. Stale comments are worse than none — the next reader believes them. ## Build and test pansy is a standalone Go module inside a parent workspace, so **`GOWORK=off` is required** or the build picks up sibling modules: ```sh GOWORK=off go build ./... GOWORK=off go test ./... cd web && npx tsc --noEmit && npx vitest run && npm run build make test # both halves make build # web bundle → embed → CGO_ENABLED=0 static binary ``` `gofmt -l internal/` before committing. Note `internal/service/plants_test.go` is already unformatted on `main` — leave it alone unless you're touching it, so the diff stays about your change. ## Architecture in one paragraph `internal/store` (hand-written SQL, `modernc.org/sqlite`, pure Go) → `internal/service` (**the seam: every permission check and invariant**) → `internal/api` (thin gin handlers: decode, call service, encode). Agent tools in `internal/agent` are equally thin adapters over the *same* service methods, so they inherit permission enforcement for free. If you are about to put a rule in a handler, put it in the service instead. Frontend: React 19 + Vite + Tailwind 4 + TanStack Router/Query, built into `internal/webdist/dist` and embedded with `embed.FS`. ## The look comes from a handoff — don't improvise it The frontend implements `docs/design_handoff_pansy_ui/` (read its README before touching anything visual; the `.dc.html` files there are references, not code). Conventions that follow from it: - **Tokens live in two places, on purpose.** Light values in `web/src/styles/index.css` (`@theme`, same names as the handoff's `styles.css`); dark values ONLY in the bootstrap script in `web/index.html`. A new color goes in both; a raw hex in a component is wrong. Tailwind's shadow utilities inline their values and can't follow the runtime override — use `.elev-sm/md/lg` instead. - **The component classes are in index.css** (`.btn`, `.input`, `.tag`, `.seg`, `.toggle`, `.chip`, `.panel`, `.dialog`). Use them with Tailwind utilities for layout rather than restyling a pill from scratch. - **The editor's breakpoint is container width** (`PHONE_BREAKPOINT` = 760 in `web/src/editor/shared.ts`, measured with a ResizeObserver), not a media query. One component tree, two chromes; don't build a second page. - **Plant markers are monograms** derived from the name (`web/src/lib/monogram.ts`); the collision set is the whole catalog so the letters match everywhere. `plant.icon` still exists in the API but nothing renders it. - **Season plans are a naming convention** (`web/src/lib/plan.ts`): a copy named `` is that garden's plan. The API keeps no link; renaming the copy quietly makes it a plain garden, which is fine. - **Tap-to-place makes a one-plant plop** (radius = spacing/2, per the handoff); "Fill the bed" (rows / clumps) is the bulk tool. Fill geometry still follows the clump rules below — different tools, not a conflict. - **Undo in the header re-reads history before reverting.** The cached list trails the canvas right after a placement, and undoing the step *before* the one you meant is the worst thing an undo button can do. Keep it that way. - **Checking a change against the handoff:** run the API on a scratch DB (`PANSY_PORT=8099 PANSY_DB=/tmp/x.db GOWORK=off go run ./cmd/pansy`) plus `PANSY_PORT=8099 npx vite` in `web/`, seed through the API, and drive the Playwright MCP at 1280×800 and 390×844. Its screenshots must be named under `.playwright-mcp/` (gitignored) or it writes them into the repo root. ## Conventions that bite if you miss them - **Everything is centimeters**, stored as SQLite `REAL`. Imperial is a display and entry concern only, in `web/src/lib/units.ts`. Two distinct scales live there: *dimension* (m/ft — gardens, objects, the garden grid) and *spacing* (cm/in — plant spacing, bed grid). Mixing them is what #47 was about. - **Optimistic concurrency everywhere.** Every mutable row has `version`; PATCH and DELETE carry it; the store returns `(current row, ErrVersionConflict)` on mismatch and the API answers 409 with the row under `"current"`. - **No-access is `ErrNotFound`, not `ErrForbidden`.** Existence is masked deliberately. `ErrForbidden` means "you can see it but may not do that". - **Plops (plantings) live in their parent object's local frame**, origin at the object's center, `-y` is north. Moving or rotating a bed moves its plants free. - **A plop is a clump, not a plant.** `defaultPlopRadius` is `1.5 × spacing`, so a plop is three spacings across and holds `π·r²/spacing²` plants. Reasoning about fills as if one plop were one plant gets the geometry wrong every time — which is how #75 happened: requiring the whole circle inside the bed inset the outer row by 1.5 spacings when the horticultural rule is *half* a spacing. Spacing is a constraint between neighbouring plants; a bed edge is nobody's neighbour. - **Soft removal**: "clear bed" sets `removed_at`; the editor reads `removed_at IS NULL`. Hard delete is a different operation. - **Length fields keep centimeters as the source of truth.** A dialog field that takes a length is a `LengthField` (`web/src/lib/units.ts`): the text is a view, `cm` changes only when the person types. Never re-parse the display string on save — "29′ 6.3″" is the nearest tenth of an inch, and parsing it back is how a no-change Save turned 900 cm into 899.922 (and bumped the version, and wrote a bogus history entry). The inspector still keeps display strings but gets the same result by refusing to commit text that still equals the formatted original (`commitDim`); either way, a no-op save sends exactly what was loaded — or nothing. - **"Today" is the browser's local day**, from `today()` in `web/src/lib/dates.ts`, and the UI always sends it: journal `observedAt`, plop/fill `plantedAt`, `removedAt`. The server's UTC default is only for API callers and the agent. A gardener placing at 9 pm in Ohio planted today, not tomorrow — don't add a UI path that leaves the date to the server. - **A wrapped `ErrInvalidInput` is shown to the person verbatim.** `fmt.Errorf("%w: chat model %q: unknown provider", domain.ErrInvalidInput, spec)` reaches the client as the 400's message (minus the sentinel prefix); the bare sentinel reads "invalid input". Write the reason for the keyboard, not the log. - **Migrations** are numbered `.sql` files in `internal/store/migrations/`, run at startup, embedded. Never edit one that has shipped. - **Every service mutation lands in history** (#48). If you add one, record it — see `internal/service/revisions.go`. Multi-row operations pass all their changes to a single `record` call so they undo as one unit. - **The history write is detached from cancellation on purpose.** `commitScope` calls `context.WithoutCancel` — that is not a mistake to tidy up. By the time a commit runs, the rows it describes are already written, so cancelling it cannot undo anything; it can only leave real changes with no way to undo them. This was a live bug twice (#73): a client disconnect mid-request orphaned 18 plantings. Fixing it per-call-site is how it came back, which is why the rule lives in `commitScope` where no caller can forget it. - **The assistant's date comes from the client, never from the model.** `POST /agent/chat` carries `today` (the browser's local day, same reason the UI sends `plantedAt`); `Runner.Run` puts it in the system prompt and `NewToolbox` stamps it on every dated tool default through `adapter.day`. A new tool that takes a date defaults through `day()`, not `time.Now()`. Left to guess, the live model dated journal entries a year back (2025) — the year it remembered from training. - **`describe_garden` is a summary, not a dump.** Plops are grouped per plant (`service.DescribeGroup`: count, where, planted date, days to maturity) and ids are listed only for groups of ≤ `maxListedPlops`; the first grid-filled garden made the old per-plop describe ~450 entries on every turn. A tool that needs individual ids uses `list_plantings`; bulk work takes (object, plant) — `remove_plantings`, `ClearPlantings`. Don't add a tool that lists plops. - **The assistant can't undo and must not pretend to.** Asked to "undo the beets", the live model replied "Done!" and changed nothing. The prompt now forbids claiming a change no tool made and points at the Undo button; keep both rules when editing `systemPrompt`. - **Request deadlines are extended through `responseController(c)`, never `http.NewResponseController(c.Writer)`.** A controller built in a handler can't reach the socket — the logging middleware wraps the writer — so every deadline call silently returns `ErrNotSupported`, in production only; `internal/api/deadlines.go` has the mechanism and why `captureController` must stay the first middleware. Corollary for tests: a deadline test must run through `New()`, not `gin.New()` — the #78 fix shipped fully tested on a bare engine and never worked on the live instance. ## Testing Match the test to the failure it would catch: - **Anything addressed by its own id needs an API-level test through the router.** Service tests can't see a route that was never registered — PATCH/DELETE `/journal/:id` once shipped fully implemented, fully unit-tested, and completely unreachable. - **Watch for fixtures that assert your assumptions instead of the API.** A test for the undo message passed because the fixture I wrote populated a field the real response leaves empty. If a test builds the thing it's testing against, it is checking your mental model, not the system. - Some things only real use finds. The agent's whole loop is covered by majordomo's scriptable fake provider (`provider/fake`), which is worth using — but the three worst v2 bugs all turned up in one live session afterwards. ## Workflow Branch → PR → **Gadfly** reviews it automatically → consider every finding and fix what's real → merge when the pipeline is green. Do not grade Gadfly findings. A push to `main` builds the image and deploys to Komodo; the live instance at `pansy.orgrimmar.dudenhoeffer.casa` updates a few minutes later. **One sweep, not a loop.** Take Gadfly's *initial* review, fix what's real, and merge once the build is green. Do NOT re-trigger Gadfly on the fix commits and wait for it again — a re-review-per-fix loop burns ~10 min a pass and drags a small PR out for an hour (Steve's explicit call, 2026-07-22). The initial review is the check; your own build/test/judgment covers the fixes. Gadfly is advisory and never blocks merge, so green build + fixes applied is enough. (Mechanics, if you ever *do* need a manual re-run: the workflow triggers on `opened`/`reopened`/`ready_for_review`, not `synchronize`, so pushes don't re-review; a `@gadfly review` comment does. A comment without that exact phrase still runs and exits green in ~2s — a skip that looks like a pass, so judge a real review by its ~10-min duration, not its status. Gadfly edits its consensus comment in place, so `updated_at` moves but `created_at` doesn't.) Workflow- and config-only changes (CI, this file, docs) go straight to `main` without the PR dance. Planning happens in Gitea issues first: standalone issues under a tracking epic, implemented in later per-issue sessions. ## Environment `PANSY_PORT`, `PANSY_DB`, `PANSY_BASE_URL`, `PANSY_REGISTRATION`, `PANSY_LOCAL_AUTH`, `PANSY_OIDC_*` — note it's `PANSY_DB` and `PANSY_PORT`, not the `_PATH`/`_ADDR` names you might guess. Authentik is the primary IdP; OIDC-first with local passwords as fallback. Agent config: `OLLAMA_CLOUD_API_KEY`, `PANSY_AGENT_MODEL` (default `ollama-cloud/glm-5.2:cloud`) and `PANSY_AGENT_ENABLED`, set in Komodo. Model strings pass verbatim to `majordomo.Parse`, so a comma-separated spec gives failover for free — don't parse that grammar in pansy. The model and enabled flag are also **admin-editable at runtime** in Settings (#79); the env vars are just defaults (precedence: DB setting → env → default). Two things this makes load-bearing: - **The live Runner is hot-swapped, not built once.** It sits behind an `atomic.Pointer` in `internal/api` (`agentHolder`), and its routes are registered *unconditionally* with a nil-check on `agent.get()`. Do NOT go back to registering the chat routes only when a key is present — a settings change has to be able to turn the assistant on without a restart, which a missing route can't. `/capabilities` reads the pointer, so it reflects the live state. - **`OLLAMA_CLOUD_API_KEY` stays in the environment, never the DB.** Model selection is a setting; the key is not. A secret in `instance_settings` lands in every backup and in the undo history's blast radius. The Settings API reports whether a key is *present*, never its value. - The "how to turn a spec into a model" knowledge lives once in `internal/agentmodel`, imported by both `agent` (to run) and `service` (to validate a spec before storing it). It can't live in `agent` — `agent` imports `service`, so a `service`→`agent` import would cycle. `majordomo` is a **real dependency** now, resolved from the Gitea instance as a pseudo-version. There is no `replace` directive and there must not be one: a `replace` pointing at `../majordomo` builds on your laptop and breaks the Docker build, which has no sibling checkout. `executus` is a sibling repo at `../` and is not a dependency. The `majordomo` build tag is **gone**. Don't reintroduce it — an untagged CI that never compiles the agent is worse than a slightly larger binary.