Files
pansy/CLAUDE.md
T
steveandClaude Fable 5 deec7bb917
Build image / build-and-push (push) Successful in 11s
Gadfly review (reusable) / review (pull_request) Successful in 10m8s
Adversarial Review (Gadfly) / review (pull_request) Successful in 10m8s
Agent: undo for real, past seasons, and tools that correct the record
Six tools the live assistant kept needing and a prompt that knows about them:

- undo_change wraps RevertChangeSet(source=agent). A revert is its own change
  set, so Run reports the last one as the turn's handle when the turn changed
  nothing else — an undo-only reply keeps its "Undo this", which is now a redo.
- describe_garden takes a year: the season view (GardenFull(year)), pulled
  plops included, with removed/removedAt per group and per plop; list_years
  says which years have records. Rotation questions finally have data.
- update_planting corrects a plop's date, count, label, radius or seed lot in
  place; remove_planting, remove_plantings and clear_object take a removedAt so
  a harvest can be backdated.
- update_journal_entry / delete_journal_entry correct a note instead of
  stacking a contradicting one.
- update_garden renames/resizes/re-units a garden and rewrites its notes — and
  the notes now go into the system prompt as the gardener's standing facts, so
  "remember we're in zone 6a" persists across conversations.

describe_garden also reports the garden's notes, version and grid, which the
new tools need. Prompt, CLAUDE.md and DESIGN.md updated to match; UI step
labels for the new tools.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-23 01:50:10 -04:00

16 KiB
Raw Blame History

CLAUDE.md

Working notes for Claude Code on pansy. 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:

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 <garden> — <year> 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. With a year it is the season view (GardenFull(year): every plop whose time in the ground overlapped the year, pulled ones included, with removed / removedAt per group) — that is how "what was here last year?" is answered.

  • The assistant undoes through undo_change, never by claiming. Asked to "undo the beets", the live model once replied "Done!" and changed nothing. undo_change wraps RevertChangeSet(source=agent); the prompt still forbids claiming a change no tool made. A revert is its own change set (it points at what it undid), so it never joins the turn's scope — Run reports the last revert as the turn's ChangeSetID when the turn made no other change, so the reply's "Undo this" is a redo. Keep that fallback: without it an undo-only turn is the one change in the conversation with no undo button.

  • Garden notes are the assistant's memory. systemPrompt quotes Garden.Notes (owner-written, %q) as standing context, and update_garden is how the model adds "we're in zone 6a" to them. Notes are replaced whole, so the tool description tells the model to merge; don't add a second store for "things the assistant remembers".

  • 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 agentagent imports service, so a serviceagent 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.