Build image / build-and-push (push) Successful in 7s
- hexCenters now derives its exact point count BEFORE building anything and returns it alongside the points, refusing over the cap without allocating. Previously it materialised the whole lattice and fillLoaded checked len() afterwards — so the "too large" path paid for the thing it was rejecting. This also makes the preallocation exact, which subsumes the earlier over-allocation finding I'd declined: staggered rows hold cols-1, so rows*cols over-reserved by ~12%. - Region.empty() names the invariant that clampTo expresses "no overlap" by INVERTING the region rather than zeroing it. A bare `MaxX < MinX` at each call site was spreading a non-obvious convention across three functions. The count is now load-bearing (it gates the cap), so the test asserts it matches what actually gets built. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
173 lines
8.8 KiB
Markdown
173 lines
8.8 KiB
Markdown
# 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.
|
||
|
||
**Gadfly reviews the PR as opened, not as merged.** The workflow triggers on
|
||
`opened`/`reopened`/`ready_for_review` — deliberately *not* `synchronize` — so
|
||
every commit you push afterwards, including the ones you push in response to
|
||
Gadfly itself, is unreviewed unless you ask. Once you've stopped pushing and
|
||
before you merge, comment **`@gadfly review`** on the PR to re-trigger it. The
|
||
phrase is required, and this is not hypothetical: on #76 the follow-up commit
|
||
was the one that contained a real bug.
|
||
|
||
**A skipped Gadfly run reports success.** A comment without the trigger phrase
|
||
still starts the workflow, which logs `comment does not contain trigger phrase`
|
||
and exits green in ~2 seconds. So "the pipeline is green" does NOT mean "this
|
||
was reviewed". Confirm a re-review actually ran by checking that it posted a new
|
||
consensus comment — or that the run took minutes rather than seconds — not by
|
||
its status.
|
||
|
||
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.
|
||
|
||
`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.
|