The toolbox could create and move but not delete or resize; write the
journal but not read it; clear a whole bed but not pull one plant; report
seed remaining but not record a purchase. Close those gaps with thin
adapters over the SAME service methods the REST API uses, so they inherit
the permission checks unchanged:
read_journal → ListJournal (the write/read asymmetry, most visible)
update_object → UpdateObject (resize / rotate / rename / plantable)
delete_object → DeleteObject (counterpart to create_object)
remove_planting → UpdatePlanting (soft-remove ONE plop, like clear does)
list_seed_lots → ListSeedLots
record_seed_lot → CreateSeedLot (record a purchase; "I bought 2 packets")
To address a single plop the agent needs its id + version, so
DescribePlanting now carries both — the same way DescribeObject.Version
already lets it edit an object. remove_planting soft-removes (removed_at =
today), mirroring clear_object, so the plant stays in planting history and
the change is undoable.
Deferred deliberately: an undo/revert tool needs a way to list recent
change sets to get a changeSetId, which is a larger addition; noted on the
issue for a follow-up.
Tested through the tool layer (TestCorrectiveTools): resize, single-plop
removal, journal read-back, seed-lot record+list, and delete.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
Gadfly findings on #95:
- Correctness (3 models): the shared inset formula radius-spacing/2
collapses to 0 in grid mode (radius = spacing/2), so grid's outer row
planted flush on / overhanging the bed edge instead of the half-spacing
in that the rule wants. The inset genuinely differs by layout — a grid
plant sits AT the plop centre (inset spacing/2), a clump's plants reach
its rim (inset radius-spacing/2, overhanging by a half). Split it into a
new edgeInset(radius, spacing, layout); hexCenters now takes a
precomputed inset and is pure geometry (no spacing/layout knowledge).
Regression guard: grid plants land at ±25 on a 60cm bed, not ±30.
- Performance (2 findings): the in-loop `existing = append(existing, *p)`
was dead — every plop in one fill shares a radius and sits on a distinct
lattice point, and a plop is "covered" only when wholly inside another,
impossible between equal-radius circles at different centres. Removing it
stops the coveredByExisting scan growing during the fill (an empty-bed
grid fill's check was needlessly quadratic in the plop count).
- Docs: FillRegion/fillLoaded/hexCenters comments and the DESIGN.md bullets
updated for plopRadiusFor/edgeInset (were still citing defaultPlopRadius
and "written out in hexCenters").
- Test hygiene: split the grid + bad-layout cases out of TestFillAndClearAPI
into TestFillLayoutAPI (one concern per test).
The enum-tag finding is a non-issue: majordomo's DefineTool derives its arg
schema from the same struct-tag reflection as Generate (proven by the vision
SeedPacket enum), and the service validates mode regardless.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
Steve chose option 3: keep clumps as the default primitive, add a grid/rows
fill mode, so sketching and planning are different operations with different
outputs rather than one model forced to be both.
A plop is a CLUMP, not a plant — great for "a few plops of garlic in a corner",
useless for drawing a plantable 8-rows-of-garlic bed (that came out as ~15
blobs, #77). FillLayout selects what a fill packs:
- clump (default, unchanged): radius 1.5×spacing, ~7 plants per plop.
- grid: radius spacing/2, pitch = spacing, ONE plant per plop — rows you could
actually plant from.
The geometry is the SAME hexCenters lattice and the SAME #75 half-spacing edge
rule; only the radius→spacing relationship differs (plopRadiusFor). Grid keeps
no 15cm floor — its whole point is true spacing — while clump keeps it so a
tiny-spacing plant doesn't make invisible clumps.
Threaded through FillRegion/FillNamedRegion (empty layout = clump, so existing
callers are unchanged; unknown layout = ErrInvalidInput), the REST /fill
endpoint (`layout`), and the agent's fill_region tool (`mode`, enum clump|grid),
so "plant the bed in rows" works.
Tests: grid produces many more, single-plant plops than clump on the same bed
(radius spacing/2, derived count 1); unknown layout is refused at both the
service and the API. maxFillPlops still caps a grid fill of a huge bed.
No frontend fill affordance exists yet (fill is agent-only in the UI; the fill
UI was deferred in #82), so the mode toggle rides along when that's built —
noted. Docs: DESIGN placement-model decision.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
- fillLoaded's doc listed what it does and omitted the non-finite-region
rejection this PR added to it.
- Trim the half-spacing rule's restatement in DESIGN.md to the decision and a
pointer. The rule, the square-foot arithmetic and the failure mode are
written out once, in hexCenters, rather than near-verbatim in four places.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
- Split hexCenters' doc: the count/limit contract had run straight on from
the #75 anti-regression paragraph with no separator, so its opening "It"
read as referring to the wrong thing.
- Write the stagger as pitch/2 rather than radius. Same value, but the intent
is "half a pitch" and only incidentally "one radius".
- fitAxis's step<=0 guard is unreachable from its only caller. Kept, and now
says so: a helper this small shouldn't need its caller read to be shown
safe, and the failure mode without it is ±Inf into an int conversion.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
- 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
- FillRegion's doc still said "half-pitch inset", left over from the first
draft of the fix; the inset is radius - spacing/2. Two docs on the same
function disagreeing is worse than either being terse.
- Reject non-finite region bounds. They survive clamping and the inverted-
region guard (NaN compares false both ways). Nothing corrupt reached the
table — SQLite stores NaN as NULL and NOT NULL refuses it — but NaN
surfaced as a raw store error and +Inf as a silent zero-plop success.
- TestHexCentersTinyRegion used a region symmetric about the origin, so it
could not distinguish "the middle of the region" from "the origin" and
would have passed for an implementation that just returned (0,0). Added an
off-centre case.
Not taken: the finding that `make(..., rows*cols)` over-allocates ~12%
because staggered rows hold cols-1. True, but the slice is capped at
maxFillPlops (5000) and the exact count needs a ceil/floor split for no
measurable gain.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
- clampTo's doc justified itself by stopping hexCenters "looping forever",
which stopped being true when hexCenters became count-bounded. Say what it
actually does now, and note the inversion the new guard relies on.
- Trim the changelog prose from hexCenters' doc down to the one line that
earns its keep: don't re-anchor at the min corner, and why.
- Rename a test local from `max` so it stops shadowing the builtin.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
clampTo INVERTS a region lying wholly outside the object — Max clamps below
Min — rather than emptying it. The old loop-until-past-MaxX form handled that
for free by never entering the loop. Counting positions up front does not:
a region 500cm east of a bed with ±50cm local bounds produced 4 plops at
x=275, a couple of metres off the bed.
Caught by removing the guard and watching the new test fail, not by assuming
it would.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
Filling a bed left the outer row too far from the edge, and staggered rows
worse still. Two defects, both from anchoring the lattice at the region's min
corner:
- Odd rows offset by `radius` started at `MinX + 2·radius`, leaving a bare
strip a whole plop wide down one side of every other row.
- All the leftover slack piled up on the far edge, where plops hung 13cm
outside the bed on a 4×8ft garlic bed. Nothing clips them, so they drew
over the bed outline.
Spacing is a constraint between neighbouring plants competing for the same
soil, light and water. A bed edge is not a competitor, so the outer row owes
it half the spacing — the arithmetic inside every square-foot-gardening chart
(4/square = 6" apart, 3" from the square's edge).
The wrinkle: a plop is a CLUMP, not a plant. defaultPlopRadius is 1.5×spacing,
so keeping the whole circle inside the bed insets the outer row by 1.5
spacings, three times what the rule allows. So centre the lattice and set the
minimum centre-inset to `radius - spacing/2`: the clump may cross the edge by
up to half a spacing, putting its outermost plants exactly the half-spacing
from the edge the rule asks for. Capped there — a clump mostly outside the bed
would be a drawing of plants in the path.
Same bed, same 15 plops, now symmetric with a deliberate 6.5cm overhang inside
the 7.5cm budget instead of an accidental 13cm on one side only. The stagger
falls out of the centring for free: an offset row holds one fewer plop, and
centring that run puts it exactly half a pitch off its neighbours.
TestFillRegionDeterministicPacking expected 4 plops in a 60×60 bed; the fourth
was centred ON the east edge with half of it outside, well past the budget.
It is 3 now — the fix working, not a regression in it.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ