Files
pansy/CLAUDE.md
T
steveandClaude Opus 4.8 84f249a774
Build image / build-and-push (push) Successful in 18s
CLAUDE.md: one Gadfly sweep, not a re-review loop
Remove the "comment @gadfly review before merge" guidance — it contradicted the
standing rule (take the initial review, fix, merge when green) and led to a
multi-round re-review loop that dragged a small PR out for an hour. Keep the
re-run mechanics as a parenthetical for the rare manual case. Steve's call,
2026-07-22.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-21 22:56:01 -04:00

191 lines
10 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`.
## 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.
- **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.
## 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.